diff --git a/apps/backend/fastapi/app/api/routes/public.py b/apps/backend/fastapi/app/api/routes/public.py index 687127c..61876af 100644 --- a/apps/backend/fastapi/app/api/routes/public.py +++ b/apps/backend/fastapi/app/api/routes/public.py @@ -1,20 +1,53 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import select +import hashlib +from datetime import datetime, timezone + +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() if ip else None + view = PublicAssetView( + public_asset_id=asset.id, + viewed_at=datetime.now(timezone.utc), + 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,78 @@ 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) + blocks = version.structured_blocks or [] if version else [] + patched = generate_patched_docx(docx_bytes, blocks) + 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, diff --git a/apps/backend/fastapi/app/models/__init__.py b/apps/backend/fastapi/app/models/__init__.py index d71a8bc..671ec37 100644 --- a/apps/backend/fastapi/app/models/__init__.py +++ b/apps/backend/fastapi/app/models/__init__.py @@ -4,6 +4,7 @@ from .cv import ( CvPatch, CvVersion, PublicAsset, + PublicAssetView, Specialization, Submission, SubmissionStatus, @@ -17,5 +18,6 @@ __all__ = [ "Submission", "SubmissionStatus", "PublicAsset", + "PublicAssetView", "AiSuggestion", ] diff --git a/apps/backend/fastapi/app/models/cv.py b/apps/backend/fastapi/app/models/cv.py index 15c7ad9..01b375c 100644 --- a/apps/backend/fastapi/app/models/cv.py +++ b/apps/backend/fastapi/app/models/cv.py @@ -1,6 +1,7 @@ from __future__ import annotations import enum +from datetime import datetime, timezone 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=lambda: datetime.now(timezone.utc), 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): diff --git a/apps/backend/fastapi/app/schemas/__init__.py b/apps/backend/fastapi/app/schemas/__init__.py index cd957a2..36def8e 100644 --- a/apps/backend/fastapi/app/schemas/__init__.py +++ b/apps/backend/fastapi/app/schemas/__init__.py @@ -4,6 +4,7 @@ from .cv import ( DocumentCreateResult, DocumentListResponse, DocumentResponse, + PublicAssetAnalyticsResponse, PublicAssetLookupResponse, PublicAssetResponse, PublishRequest, @@ -28,4 +29,5 @@ __all__ = [ "PublishRequest", "PublicAssetResponse", "PublicAssetLookupResponse", + "PublicAssetAnalyticsResponse", ] diff --git a/apps/backend/fastapi/app/schemas/cv.py b/apps/backend/fastapi/app/schemas/cv.py index b464962..1262d53 100644 --- a/apps/backend/fastapi/app/schemas/cv.py +++ b/apps/backend/fastapi/app/schemas/cv.py @@ -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 diff --git a/apps/webapp/src/app/dashboard/page.tsx b/apps/webapp/src/app/dashboard/page.tsx index e2f6edf..a0ce5bf 100644 --- a/apps/webapp/src/app/dashboard/page.tsx +++ b/apps/webapp/src/app/dashboard/page.tsx @@ -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(null); - const [publishedUrl, setPublishedUrl] = useState(null); + const [publishedAsset, setPublishedAsset] = useState(null); + const [publishedAnalytics, setPublishedAnalytics] = useState(null); const [activeTab, setActiveTab] = useState('content'); const [submissions, setSubmissions] = useState([]); const [subsLoading, setSubsLoading] = useState(false); @@ -762,14 +765,35 @@ export default function Dashboard() { - {publishedUrl && ( + {publishedAsset && (
- Published: - {publishedUrl} - +
+ Published + {publishedAnalytics !== null && ( + + {publishedAnalytics.view_count} view{publishedAnalytics.view_count !== 1 ? 's' : ''} + + )} + + +
+
)} @@ -861,7 +885,7 @@ export default function Dashboard() { setModal(null)} - onDone={url => { setPublishedUrl(url); setModal(null); }} + onDone={asset => { setPublishedAsset(asset); setPublishedAnalytics(null); setModal(null); }} /> )} diff --git a/apps/webapp/src/libs/api.ts b/apps/webapp/src/libs/api.ts index 99ae46b..c744664 100644 --- a/apps/webapp/src/libs/api.ts +++ b/apps/webapp/src/libs/api.ts @@ -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 { 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 => + req(`/api/v1/public/${encodeURIComponent(slug)}/analytics`); + export async function deleteDocument(documentId: string): Promise { const res = await fetch(`${API}/api/v1/documents/${documentId}`, { method: 'DELETE', diff --git a/dlib/cv/__init__.py b/dlib/cv/__init__.py index 0a15cbc..2b6c89f 100644 --- a/dlib/cv/__init__.py +++ b/dlib/cv/__init__.py @@ -9,6 +9,7 @@ from .parser import parse_docx_bytes, summarize_keywords from .patcher import apply_patchset from .ats_guard import validate_patchset from .docx_export import generate_patched_docx +from .pdf_export import docx_bytes_to_pdf __all__ = [ "StructuredBlock", @@ -21,4 +22,5 @@ __all__ = [ "apply_patchset", "validate_patchset", "generate_patched_docx", + "docx_bytes_to_pdf", ] diff --git a/dlib/cv/pdf_export.py b/dlib/cv/pdf_export.py new file mode 100644 index 0000000..dbce746 --- /dev/null +++ b/dlib/cv/pdf_export.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from io import BytesIO + +from docx import Document +from reportlab.lib import colors +from reportlab.lib.enums import TA_CENTER, TA_LEFT +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import cm +from reportlab.platypus import HRFlowable, Paragraph, SimpleDocTemplate, Spacer + +from .parser import _detect_block_type + +_STYLES = getSampleStyleSheet() + +_STYLE_MAP: dict[str, ParagraphStyle] = { + "heading": ParagraphStyle( + "CVHeading", parent=_STYLES["Normal"], + fontSize=15, leading=20, spaceBefore=10, spaceAfter=4, + textColor=colors.HexColor("#111111"), fontName="Helvetica-Bold", + ), + "meta": ParagraphStyle( + "CVMeta", parent=_STYLES["Normal"], + fontSize=9, leading=13, spaceAfter=2, textColor=colors.HexColor("#555555"), + ), + "summary": ParagraphStyle( + "CVSummary", parent=_STYLES["Normal"], + fontSize=10, leading=14, spaceAfter=6, textColor=colors.HexColor("#333333"), + ), + "bullet": ParagraphStyle( + "CVBullet", parent=_STYLES["Normal"], + fontSize=10, leading=14, spaceAfter=3, leftIndent=14, + bulletIndent=0, textColor=colors.HexColor("#222222"), + ), + "skills": ParagraphStyle( + "CVSkills", parent=_STYLES["Normal"], + fontSize=10, leading=14, spaceAfter=3, textColor=colors.HexColor("#222222"), + ), + "text": ParagraphStyle( + "CVText", parent=_STYLES["Normal"], + fontSize=10, leading=14, spaceAfter=4, textColor=colors.HexColor("#222222"), + ), +} + + +def docx_bytes_to_pdf(docx_bytes: bytes) -> bytes: + doc = Document(BytesIO(docx_bytes)) + buf = BytesIO() + pdf = SimpleDocTemplate( + buf, pagesize=A4, + leftMargin=2.2 * cm, rightMargin=2.2 * cm, + topMargin=2 * cm, bottomMargin=2 * cm, + ) + story: list = [] + prev_type: str | None = None + + for para in doc.paragraphs: + text = para.text.strip() + if not text: + if prev_type and prev_type != "empty": + story.append(Spacer(1, 4)) + prev_type = "empty" + continue + + block_type = _detect_block_type(getattr(para.style, "name", None), para) + style = _STYLE_MAP.get(block_type, _STYLE_MAP["text"]) + + # draw separator line before new heading sections (except first) + if block_type == "heading" and prev_type not in (None, "heading"): + story.append(Spacer(1, 6)) + story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#cccccc"))) + + prefix = "\u2022\u00a0" if block_type == "bullet" else "" + story.append(Paragraph(f"{prefix}{text}", style)) + prev_type = block_type + + pdf.build(story) + return buf.getvalue() diff --git a/pyproject.toml b/pyproject.toml index deb1503..4cb4fe9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "python-multipart", "pyyaml", "python-jose[cryptography]", + "reportlab", "sqlalchemy[asyncio]", "asyncpg", "redis",