diff --git a/apps/backend/fastapi/app/api/routes/documents.py b/apps/backend/fastapi/app/api/routes/documents.py index 3916621..0423a44 100644 --- a/apps/backend/fastapi/app/api/routes/documents.py +++ b/apps/backend/fastapi/app/api/routes/documents.py @@ -5,8 +5,14 @@ from fastapi.responses import Response 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.schemas import DocumentListResponse, DocumentResponse -from app.services.documents import create_document, delete_document, get_document, list_documents +from app.services.documents import ( + create_document, + delete_document, + get_document, + list_documents, +) from app.services.storage import storage_client from dlib.auth import AuthenticatedUser from dlib.cv import generate_patched_docx @@ -21,7 +27,7 @@ async def list_user_documents( user: AuthenticatedUser = Depends(get_current_user), ): documents = await list_documents(session, owner_id=user.sub) - payload = [DocumentResponse.model_validate(doc) for doc in documents] + payload = [_build_document_response(doc) for doc in documents] return DocumentListResponse(items=payload) @@ -34,7 +40,7 @@ async def get_user_document( document = await get_document(session, owner_id=user.sub, document_id=document_id) if not document: raise HTTPException(status_code=404, detail="Document not found") - return DocumentResponse.model_validate(document) + return _build_document_response(document) @router.get("/{document_id}/versions/{version_id}/download") @@ -75,7 +81,7 @@ async def upload_document( description=description, upload=file, ) - return DocumentResponse.model_validate(document) + return _build_document_response(document) @router.delete("/{document_id}", status_code=204) @@ -87,3 +93,20 @@ async def delete_user_document( deleted = await delete_document(session, owner_id=user.sub, document_id=document_id) if not deleted: raise HTTPException(status_code=404, detail="Document not found") + + +def _build_document_response(document) -> DocumentResponse: + settings = get_settings() + base = (settings.public_base_url or "").rstrip("/") + response = DocumentResponse.model_validate(document) + versions = [] + for version in response.versions: + assets = [] + for asset in version.public_assets: + slug = asset.slug + url = asset.url + if slug and not url: + url = f"{base}/cv/{slug}" if base else f"/cv/{slug}" + assets.append(asset.model_copy(update={"url": url})) + versions.append(version.model_copy(update={"public_assets": assets})) + return response.model_copy(update={"versions": versions}) diff --git a/apps/backend/fastapi/app/schemas/cv.py b/apps/backend/fastapi/app/schemas/cv.py index 5d797f5..9a0d645 100644 --- a/apps/backend/fastapi/app/schemas/cv.py +++ b/apps/backend/fastapi/app/schemas/cv.py @@ -34,6 +34,7 @@ class VersionResponse(BaseModel): created_at: datetime updated_at: datetime patches: list["PatchResponse"] = Field(default_factory=list) + public_assets: list["PublicAssetResponse"] = Field(default_factory=list) class PatchResponse(BaseModel): diff --git a/apps/backend/fastapi/app/services/documents.py b/apps/backend/fastapi/app/services/documents.py index f6eb65f..739bad0 100644 --- a/apps/backend/fastapi/app/services/documents.py +++ b/apps/backend/fastapi/app/services/documents.py @@ -43,7 +43,12 @@ async def create_document( stmt = ( select(CvDocument) .where(CvDocument.id == doc.id) - .options(selectinload(CvDocument.versions).selectinload(CvVersion.patches)) + .options( + selectinload(CvDocument.versions).options( + selectinload(CvVersion.patches), + selectinload(CvVersion.public_assets), + ) + ) ) result = await session.execute(stmt) return result.scalars().unique().one() @@ -53,7 +58,12 @@ async def list_documents(session: AsyncSession, owner_id: str) -> list[CvDocumen stmt = ( select(CvDocument) .where(CvDocument.owner_id == owner_id) - .options(selectinload(CvDocument.versions).selectinload(CvVersion.patches)) + .options( + selectinload(CvDocument.versions).options( + selectinload(CvVersion.patches), + selectinload(CvVersion.public_assets), + ) + ) .order_by(CvDocument.created_at.desc()) ) result = await session.execute(stmt) @@ -66,7 +76,12 @@ async def get_document( stmt = ( select(CvDocument) .where(CvDocument.id == document_id, CvDocument.owner_id == owner_id) - .options(selectinload(CvDocument.versions).selectinload(CvVersion.patches)) + .options( + selectinload(CvDocument.versions).options( + selectinload(CvVersion.patches), + selectinload(CvVersion.public_assets), + ) + ) ) result = await session.execute(stmt) return result.scalars().unique().one_or_none() diff --git a/apps/webapp/src/app/dashboard/page.tsx b/apps/webapp/src/app/dashboard/page.tsx index cff2b1c..c1d8b1f 100644 --- a/apps/webapp/src/app/dashboard/page.tsx +++ b/apps/webapp/src/app/dashboard/page.tsx @@ -481,8 +481,8 @@ export default function Dashboard() { const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [modal, setModal] = useState(null); - const [publishedAsset, setPublishedAsset] = useState(null); - const [publishedAnalytics, setPublishedAnalytics] = useState(null); + const [publishedAnalytics, setPublishedAnalytics] = useState>({}); + const [recentlyPublishedSlug, setRecentlyPublishedSlug] = useState(null); const [activeTab, setActiveTab] = useState('content'); const [submissions, setSubmissions] = useState([]); const [subsLoading, setSubsLoading] = useState(false); @@ -506,6 +506,8 @@ export default function Dashboard() { setPendingEdits(new Map()); setApplyError(''); setApplyLoading(false); + setPublishedAnalytics({}); + setRecentlyPublishedSlug(null); }, [selectedVersionId]); useEffect(() => { @@ -519,6 +521,24 @@ export default function Dashboard() { const selectedDoc = docs.find(d => d.id === selectedDocId) ?? null; const selectedVersion = selectedDoc?.versions.find(v => v.id === selectedVersionId) ?? null; + const publishedAssets = selectedVersion?.public_assets ?? []; + const sortedPublishedAssets = [...publishedAssets].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + const publicBaseUrl = (process.env.NEXT_PUBLIC_BASE_URL ?? '').replace(/\/$/, ''); + + const resolveAssetUrl = (asset: PublicAsset): string => { + if (asset.url) return asset.url; + if (publicBaseUrl) return `${publicBaseUrl}/cv/${asset.slug}`; + return `/cv/${asset.slug}`; + }; + + const loadPublishedAnalytics = async (slug: string) => { + try { + const stats = await fetchPublicAssetAnalytics(slug); + setPublishedAnalytics(prev => ({ ...prev, [slug]: stats })); + } catch { + // swallow for now; UI button can be retried + } + }; const refreshDocs = async () => { const fresh = await fetchDocuments().catch(() => docs); @@ -785,34 +805,64 @@ export default function Dashboard() { - {publishedAsset && ( + {sortedPublishedAssets.length > 0 && (
-
- Published - {publishedAnalytics !== null && ( - - {publishedAnalytics.view_count} view{publishedAnalytics.view_count !== 1 ? 's' : ''} +
+ Published variants ({sortedPublishedAssets.length}) + {recentlyPublishedSlug && ( + + Latest: {recentlyPublishedSlug} )} - -
-
- - Share link - - | - - View PDF - +
+ {sortedPublishedAssets.map(asset => { + const stats = publishedAnalytics[asset.slug]; + return ( +
+
+ {asset.slug} + {fmt(asset.created_at)} + {recentlyPublishedSlug === asset.slug && ( + New + )} +
+ +
+ + {stats + ? ( + <> + {stats.view_count} view{stats.view_count !== 1 ? 's' : ''} + {stats.last_viewed_at && ( + <> · last {fmt(stats.last_viewed_at)} + )} + + ) + : 'No stats yet'} + + +
+
+ ); + })}
)} @@ -914,7 +964,12 @@ export default function Dashboard() { setModal(null)} - onDone={asset => { setPublishedAsset(asset); setPublishedAnalytics(null); setModal(null); }} + onDone={asset => { + setModal(null); + setRecentlyPublishedSlug(asset.slug); + setPublishedAnalytics({}); + refreshDocs(); + }} /> )}
diff --git a/apps/webapp/src/libs/api.ts b/apps/webapp/src/libs/api.ts index c0108fd..4d922ea 100644 --- a/apps/webapp/src/libs/api.ts +++ b/apps/webapp/src/libs/api.ts @@ -25,6 +25,7 @@ export type Version = { structured_blocks?: StructuredBlock[] | null; artifact_docx_key?: string | null; patches: Patch[]; + public_assets: PublicAsset[]; created_at: string; updated_at: string; };