diff --git a/backend/app/api/v1/certificates.py b/backend/app/api/v1/certificates.py index 4e21d77..ed2b158 100644 --- a/backend/app/api/v1/certificates.py +++ b/backend/app/api/v1/certificates.py @@ -2,8 +2,10 @@ from __future__ import annotations import uuid +from io import BytesIO from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import HTMLResponse, StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -12,15 +14,36 @@ from app.models.user import User from app.models.donation_certificate import DonationCertificate from app.models.donation import Donation +from app.models.campaign_drive import CampaignDrive from app.schemas.donation_certificate import ( DonationCertificateRead, DonationCertificateUpdate, ) -from app.storage.s3 import generate_presigned_download_url +from app.certificates.generator import generate_certificate_pdf router = APIRouter(prefix="/certificates", tags=["certificates"]) +def _campaign_id_from_purpose(purpose: str | None) -> str | None: + if not purpose or not purpose.startswith("campaign:"): + return None + return purpose.replace("campaign:", "", 1) + + +async def _campaign_title_for_donation(db: AsyncSession, donation: Donation) -> str: + campaign_id = _campaign_id_from_purpose(donation.purpose) + if not campaign_id: + return "LINGAP Campaign" + campaign = ( + await db.execute(select(CampaignDrive).where(CampaignDrive.id == campaign_id)) + ).scalar_one_or_none() + return campaign.title if campaign else "LINGAP Campaign" + + +async def _get_donation(db: AsyncSession, donation_id: uuid.UUID) -> Donation | None: + return (await db.execute(select(Donation).where(Donation.id == donation_id))).scalar_one_or_none() + + @router.get("/{cert_id}", response_model=DonationCertificateRead) async def get_certificate( cert_id: uuid.UUID, @@ -37,12 +60,57 @@ async def get_certificate( if not cert: raise HTTPException(404, "Certificate not found") - if not cert.is_public and (not user or user.id != cert.donation.donor_id): + donation = await _get_donation(db, cert.donation_id) + if not donation: + raise HTTPException(404, "Donation record not found") + if not cert.is_public and (not user or user.id != donation.donor_id): raise HTTPException(403, "Not authorized to view this certificate") return DonationCertificateRead.model_validate(cert) +@router.get("/{cert_id}/public") +async def get_public_certificate_page( + cert_id: uuid.UUID, + db: AsyncSession = Depends(get_db), +): + cert = ( + await db.execute( + select(DonationCertificate).where(DonationCertificate.id == cert_id) + ) + ).scalar_one_or_none() + + if not cert or not cert.is_public: + raise HTTPException(404, "Public certificate not found") + + donation = await _get_donation(db, cert.donation_id) + if not donation: + raise HTTPException(404, "Donation record not found") + campaign_name = await _campaign_title_for_donation(db, donation) + donor = (await db.execute(select(User).where(User.id == donation.donor_id))).scalar_one_or_none() + donor_name = cert.donor_name or (donor.stellar_public_key if donor else None) or "Anonymous Donor" + tx_hash = cert.stellar_tx_hash or donation.stellar_tx_hash or "N/A" + html = f""" + + + LINGAP Certificate {cert.id} + +
+

LINGAP On-Chain Donation Proof

+

Public Impact Certificate

+

User Name: {donor_name}

+

Campaign Name: {campaign_name}

+

Milestone: {cert.milestone_description}

+

Donation Amount: {float(cert.amount):,.2f} XLM

+

Transaction Hash: {tx_hash}

+

Verification: {"Verified" if cert.verified else "Pending"}

+
+ + + """ + return HTMLResponse(content=html) + + @router.get("/donation/{donation_id}", response_model=DonationCertificateRead) async def get_certificate_by_donation( donation_id: uuid.UUID, @@ -61,7 +129,10 @@ async def get_certificate_by_donation( if not cert: raise HTTPException(404, "Certificate not found for this donation") - if not cert.is_public and user.id != cert.donation.donor_id: + donation = await _get_donation(db, cert.donation_id) + if not donation: + raise HTTPException(404, "Donation record not found") + if not cert.is_public and user.id != donation.donor_id: raise HTTPException(403, "Not authorized to view this certificate") return DonationCertificateRead.model_validate(cert) @@ -84,7 +155,10 @@ async def update_certificate_visibility( if not cert: raise HTTPException(404, "Certificate not found") - if user.id != cert.donation.donor_id: + donation = await _get_donation(db, cert.donation_id) + if not donation: + raise HTTPException(404, "Donation record not found") + if user.id != donation.donor_id: raise HTTPException(403, "Only certificate owner can update visibility") cert.is_public = body.is_public @@ -100,7 +174,7 @@ async def download_certificate( db: AsyncSession = Depends(get_db), user: User | None = Depends(get_current_user), ): - """Get presigned download URL for certificate PDF.""" + """Render certificate PDF on demand and stream it.""" cert = ( await db.execute( select(DonationCertificate).where(DonationCertificate.id == cert_id) @@ -110,13 +184,36 @@ async def download_certificate( if not cert: raise HTTPException(404, "Certificate not found") - if not cert.is_public and (not user or user.id != cert.donation.donor_id): + donation = await _get_donation(db, cert.donation_id) + if not donation: + raise HTTPException(404, "Donation record not found") + if not cert.is_public and (not user or user.id != donation.donor_id): raise HTTPException(403, "Not authorized to download this certificate") - s3_key = f"certificates/{cert.donation_id}/{cert.pdf_hash[:16]}.pdf" - presigned_url = await generate_presigned_download_url(s3_key) + campaign_name = await _campaign_title_for_donation(db, donation) + milestone = cert.milestone_description or "Campaign milestone completed" + donor = (await db.execute(select(User).where(User.id == donation.donor_id))).scalar_one_or_none() + donor_name = cert.donor_name or (donor.name if donor else None) or (donor.stellar_public_key if donor else None) or "Anonymous Donor" + tx_hash = cert.stellar_tx_hash or donation.stellar_tx_hash or "N/A" + + pdf_bytes = generate_certificate_pdf( + donor_name=donor_name, + amount=float(cert.amount), + beneficiary_name=campaign_name, + milestone_description=milestone, + lives_touched=int(cert.lives_touched or 0), + total_donated=float(cert.total_donated or cert.amount), + current_donation=float(cert.amount), + donation_date=donation.created_at, + stellar_tx_hash=tx_hash, + ) - return {"download_url": presigned_url, "filename": f"certificate-{cert.donation_id}.pdf"} + filename = f"certificate-{cert.donation_id}.pdf" + return StreamingResponse( + BytesIO(pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) @router.get("/donor/{donor_id}/all", response_model=list[DonationCertificateRead]) @@ -140,3 +237,46 @@ async def list_donor_certificates( certificates = result.scalars().all() return [DonationCertificateRead.model_validate(c) for c in certificates] + + +@router.get("") +async def list_certificates( + donor_id: uuid.UUID | None = None, + db: AsyncSession = Depends(get_db), + user: User | None = Depends(get_current_user), +): + query = select(DonationCertificate).order_by(DonationCertificate.created_at.desc()) + if donor_id: + query = query.where(DonationCertificate.donation.has(donor_id=donor_id)) + + certs = (await db.execute(query)).scalars().all() + items = [] + for cert in certs: + if not cert.is_public and (not user or user.id != cert.donation.donor_id): + donation = await _get_donation(db, cert.donation_id) + if not donation: + continue + if not user or user.id != donation.donor_id: + continue + donation = await _get_donation(db, cert.donation_id) + if not donation: + continue + campaign_name = await _campaign_title_for_donation(db, donation) + items.append( + { + "id": str(cert.id), + "donation_id": str(cert.donation_id), + "donor_name": cert.donor_name, + "amount": float(cert.amount), + "beneficiary_name": cert.beneficiary_name, + "campaign_name": campaign_name, + "milestone_description": cert.milestone_description, + "stellar_tx_hash": cert.stellar_tx_hash, + "verified": cert.verified, + "is_public": cert.is_public, + "created_at": cert.created_at, + "public_url": f"/api/v1/certificates/{cert.id}/public" if cert.is_public else None, + "download_url": f"/api/v1/certificates/{cert.id}/download", + } + ) + return {"success": True, "data": items} diff --git a/backend/app/api/v1/profiles.py b/backend/app/api/v1/profiles.py index 5a5d22e..b7cca2a 100644 --- a/backend/app/api/v1/profiles.py +++ b/backend/app/api/v1/profiles.py @@ -101,6 +101,11 @@ async def _profile_payload(db: AsyncSession, user: User) -> dict: ).one() campaigns = await _public_campaigns_for_user(db, user) + campaign_rows = ( + await db.execute(select(CampaignDrive.id, CampaignDrive.title)) + ).all() + campaign_titles = {str(row.id): row.title for row in campaign_rows} + cert_rows = ( await db.execute( select(DonationCertificate, Donation) @@ -116,7 +121,8 @@ async def _profile_payload(db: AsyncSession, user: User) -> dict: activity_rows = ( await db.execute( - select(Donation) + select(Donation, DonationCertificate) + .outerjoin(DonationCertificate, DonationCertificate.donation_id == Donation.id) .where( Donation.donor_id == user.id, Donation.purpose.like("campaign:%"), @@ -124,7 +130,7 @@ async def _profile_payload(db: AsyncSession, user: User) -> dict: .order_by(Donation.created_at.desc()) .limit(8) ) - ).scalars().all() + ).all() total_xlm = float(donation_row.total or 0) donation_count = int(donation_row.count or 0) @@ -135,6 +141,8 @@ async def _profile_payload(db: AsyncSession, user: User) -> dict: "amount": float(cert.amount), "lives_touched": cert.lives_touched, "stellar_tx_hash": cert.stellar_tx_hash, + "public_url": f"/api/v1/certificates/{cert.id}/public", + "download_url": f"/api/v1/certificates/{cert.id}/download", "verified": cert.verified, "created_at": cert.created_at, } @@ -144,13 +152,17 @@ async def _profile_payload(db: AsyncSession, user: User) -> dict: { "id": str(donation.id), "campaign_id": (donation.purpose or "").replace("campaign:", "", 1), + "campaign_name": campaign_titles.get((donation.purpose or "").replace("campaign:", "", 1), "Campaign"), + "milestone": cert.milestone_description if cert else None, "amount": float(donation.amount), "asset": donation.asset, "stellar_tx_hash": donation.stellar_tx_hash, + "wallet_address": user.stellar_public_key, + "certificate_id": str(cert.id) if cert else None, "blockchain_confirmed": donation.blockchain_confirmed, "created_at": donation.created_at, } - for donation in activity_rows + for donation, cert in activity_rows ] active_campaigns = [item for item in campaigns if item["status"].lower() == "active"] diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 1f353cb..8a4f765 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -19,6 +19,8 @@ from .campaigns import router as campaigns_router from .profiles import router as profiles_router from .balance import router as balance_router +from .certificates import router as certificates_router +from .onchain_certificates import router as onchain_certificates_router api_router = APIRouter(prefix="/api/v1") @@ -41,3 +43,5 @@ api_router.include_router(campaigns_router) api_router.include_router(profiles_router) api_router.include_router(balance_router) +api_router.include_router(certificates_router) +api_router.include_router(onchain_certificates_router) diff --git a/backend/app/certificates/service.py b/backend/app/certificates/service.py index 40db6fe..cfd6388 100644 --- a/backend/app/certificates/service.py +++ b/backend/app/certificates/service.py @@ -14,6 +14,7 @@ from app.models.beneficiary import Beneficiary from app.models.user import User from app.models.provenance import ProvenanceRecord +from app.models.campaign_drive import CampaignDrive from app.certificates.generator import generate_certificate_pdf from app.storage.s3 import upload_certificate_pdf @@ -97,12 +98,20 @@ async def create_certificate_for_donation( ).scalar() total_donated = float(total_donated_result or 0) - milestone_description = ( - aid_request.purpose or f"Donation for {beneficiary.name}" - ) + campaign_title = None + if donation.purpose and donation.purpose.startswith("campaign:"): + campaign_id = donation.purpose.replace("campaign:", "", 1) + campaign = ( + await db.execute(select(CampaignDrive).where(CampaignDrive.id == campaign_id)) + ).scalar_one_or_none() + if campaign: + campaign_title = campaign.title + + milestone_description = aid_request.purpose or campaign_title or f"Donation for {beneficiary.name}" + donor_name = donor.name or donor.stellar_public_key or donor.email pdf_bytes = generate_certificate_pdf( - donor_name=donor.name, + donor_name=donor_name, amount=float(donation.amount), beneficiary_name=beneficiary.name, milestone_description=milestone_description, @@ -120,7 +129,7 @@ async def create_certificate_for_donation( s3_url=stored.s3_url, pdf_hash=stored.pdf_hash, is_public=False, - donor_name=donor.name, + donor_name=donor_name, amount=float(donation.amount), beneficiary_name=beneficiary.name, milestone_description=milestone_description, diff --git a/frontend/src/app/(marketing)/certificate/page.tsx b/frontend/src/app/(marketing)/certificate/page.tsx index 2ca6fd8..dfd43cd 100644 --- a/frontend/src/app/(marketing)/certificate/page.tsx +++ b/frontend/src/app/(marketing)/certificate/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useMemo, useState, useEffect } from "react"; +import { useSearchParams } from "next/navigation"; import { ArrowLeft, Award, @@ -48,13 +49,18 @@ const themeAccent = { export default function CertificatePage() { const { user } = useAuthStore(); + const searchParams = useSearchParams(); const [selected, setSelected] = useState(null); const [userCertificates, setUserCertificates] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { - // FORCE using the mock user ID for demonstration purposes so you can always see the certificates! - const donorId = "ec108cc1-c6fa-4d91-953c-df1324ce17d9"; + if (!user?.id) { + setLoading(false); + return; + } + const donorId = user.id; + const preselectCertId = searchParams.get("cert"); certificatesApi.listByDonor(donorId) .then((res) => { @@ -63,7 +69,7 @@ export default function CertificatePage() { id: c.id, donor: c.donor_name || user?.name || "Anonymous Donor", amount: `₱${parseFloat(c.amount || "0").toLocaleString(undefined, { minimumFractionDigits: 2 })}`, - campaign: c.milestone_description || "Campaign Milestone", + campaign: c.campaign_name || c.milestone_description || "Campaign Milestone", institution: c.beneficiary_name || "LINGAP Verified Network", milestone: c.milestone_description || "Milestone Complete", date: new Date(c.created_at).toLocaleDateString(), @@ -73,6 +79,10 @@ export default function CertificatePage() { theme: "medical", })); setUserCertificates(mapped); + if (preselectCertId) { + const match = mapped.find((item) => item.id === preselectCertId); + if (match) setSelected(match); + } }) .catch((err) => { console.error(err); @@ -81,7 +91,7 @@ export default function CertificatePage() { .finally(() => { setLoading(false); }); - }, [user]); + }, [user, searchParams]); const hasCertificates = userCertificates.length > 0; const selectedShareUrl = useMemo(() => selected?.stellarUrl ?? "", [selected]); @@ -237,7 +247,7 @@ export default function CertificatePage() {