Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions backend/app/api/v1/certificates.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from sqlalchemy import select

from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.core.dependencies import get_current_user, get_current_user_optional
from app.models.user import User
from app.models.donation_certificate import DonationCertificate
from app.models.donation import Donation
Expand Down Expand Up @@ -48,7 +48,7 @@ async def _get_donation(db: AsyncSession, donation_id: uuid.UUID) -> Donation |
async def get_certificate(
cert_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user: User | None = Depends(get_current_user),
user: User | None = Depends(get_current_user_optional),
):
"""Get certificate details. Public certificates visible to all, private only to owner."""
cert = (
Expand Down Expand Up @@ -172,7 +172,7 @@ async def update_certificate_visibility(
async def download_certificate(
cert_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user: User | None = Depends(get_current_user),
user: User | None = Depends(get_current_user_optional),
):
"""Render certificate PDF on demand and stream it."""
cert = (
Expand Down Expand Up @@ -220,7 +220,7 @@ async def download_certificate(
async def list_donor_certificates(
donor_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user: User | None = Depends(get_current_user),
user: User | None = Depends(get_current_user_optional),
):
"""List all certificates for a donor. Public certs visible to all, private only to owner."""
if not user or (user.id != donor_id and user.role.value != "admin"):
Expand All @@ -243,7 +243,7 @@ async def list_donor_certificates(
async def list_certificates(
donor_id: uuid.UUID | None = None,
db: AsyncSession = Depends(get_db),
user: User | None = Depends(get_current_user),
user: User | None = Depends(get_current_user_optional),
):
query = select(DonationCertificate).order_by(DonationCertificate.created_at.desc())
if donor_id:
Expand All @@ -252,15 +252,11 @@ async def list_certificates(
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
if not cert.is_public and (not user or user.id != donation.donor_id):
continue
campaign_name = await _campaign_title_for_donation(db, donation)
items.append(
{
Expand Down
95 changes: 72 additions & 23 deletions backend/app/certificates/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
logger = logging.getLogger(__name__)


def _fallback_campaign_title(campaign_id: str) -> str:
return campaign_id.replace("-", " ").replace("_", " ").title()


async def create_certificate_for_donation(
donation: Donation,
db: AsyncSession,
Expand All @@ -42,13 +46,42 @@ async def create_certificate_for_donation(
logger.warning(f"Donation {donation.id} not confirmed, skipping certificate")
return None

existing = (
await db.execute(
select(DonationCertificate).where(DonationCertificate.donation_id == donation.id)
)
).scalar_one_or_none()
if existing:
return existing

donor = (
await db.execute(select(User).where(User.id == donation.donor_id))
).scalar_one_or_none()
if not donor:
logger.error(f"Donor not found for donation {donation.id}")
return None

campaign_id = None
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()
campaign_title = campaign.title if campaign else _fallback_campaign_title(campaign_id)

donor_name = donor.name or donor.stellar_public_key or donor.email

total_donated_result = (
await db.execute(
select(func.sum(Donation.amount)).where(
Donation.donor_id == donation.donor_id,
Donation.blockchain_confirmed == True,
)
)
).scalar()
total_donated = float(total_donated_result or 0)

provenance_records = (
await db.execute(
select(ProvenanceRecord).where(
Expand All @@ -58,8 +91,42 @@ async def create_certificate_for_donation(
).scalars().all()

if not provenance_records:
logger.warning(f"No provenance records for donation {donation.id}")
return None
milestone_description = campaign_title or "Campaign donation completed"
beneficiary_name = campaign_title or "LINGAP Campaign"
lives_touched = 0

pdf_bytes = generate_certificate_pdf(
donor_name=donor_name,
amount=float(donation.amount),
beneficiary_name=beneficiary_name,
milestone_description=milestone_description,
lives_touched=lives_touched,
total_donated=total_donated,
current_donation=float(donation.amount),
donation_date=donation.created_at,
stellar_tx_hash=donation.stellar_tx_hash,
)

stored = await upload_certificate_pdf(pdf_bytes, str(donation.id))
certificate = DonationCertificate(
donation_id=donation.id,
s3_url=stored.s3_url,
pdf_hash=stored.pdf_hash,
is_public=True,
donor_name=donor_name,
amount=float(donation.amount),
beneficiary_name=beneficiary_name,
milestone_description=milestone_description,
lives_touched=lives_touched,
total_donated=total_donated,
stellar_tx_hash=donation.stellar_tx_hash,
verified=True,
)
db.add(certificate)
await db.commit()
await db.refresh(certificate)
logger.info(f"Fallback campaign certificate created for donation {donation.id}")
return certificate

prov = provenance_records[0]

Expand Down Expand Up @@ -88,27 +155,7 @@ async def create_certificate_for_donation(
).scalar()
lives_touched = lives_touched_result or 0

total_donated_result = (
await db.execute(
select(func.sum(Donation.amount)).where(
Donation.donor_id == donation.donor_id,
Donation.blockchain_confirmed == True,
)
)
).scalar()
total_donated = float(total_donated_result or 0)

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,
Expand All @@ -128,13 +175,15 @@ async def create_certificate_for_donation(
donation_id=donation.id,
s3_url=stored.s3_url,
pdf_hash=stored.pdf_hash,
is_public=False,
is_public=True,
donor_name=donor_name,
amount=float(donation.amount),
beneficiary_name=beneficiary.name,
milestone_description=milestone_description,
lives_touched=lives_touched,
total_donated=total_donated,
stellar_tx_hash=donation.stellar_tx_hash,
verified=True,
)

db.add(certificate)
Expand Down
15 changes: 15 additions & 0 deletions backend/app/core/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import uuid

bearer = HTTPBearer()
optional_bearer = HTTPBearer(auto_error=False)


async def get_current_user(
Expand All @@ -27,6 +28,20 @@ async def get_current_user(
return user


async def get_current_user_optional(
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
db: AsyncSession = Depends(get_db),
) -> User | None:
if credentials is None:
return None
try:
user_id = decode_token(credentials.credentials)
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
return result.scalar_one_or_none()
except Exception:
return None


async def require_admin(user: User = Depends(get_current_user)) -> User:
if _role_value(user.role) != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
Expand Down
22 changes: 15 additions & 7 deletions frontend/src/app/(marketing)/certificate/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ export default function CertificatePage() {
const [selected, setSelected] = useState<Certificate | null>(null);
const [userCertificates, setUserCertificates] = useState<Certificate[]>([]);
const [loading, setLoading] = useState(true);
const targetUserId = searchParams.get("user");
const isOwnView = !targetUserId || targetUserId === user?.id;

useEffect(() => {
if (!user?.id) {
const donorId = targetUserId || user?.id;
if (!donorId) {
setLoading(false);
return;
}
const donorId = user.id;
const preselectCertId = searchParams.get("cert");

certificatesApi.listByDonor(donorId)
Expand All @@ -86,12 +88,12 @@ export default function CertificatePage() {
})
.catch((err) => {
console.error(err);
toast.error("Failed to load your certificates.");
toast.error("Failed to load certificates.");
})
.finally(() => {
setLoading(false);
});
}, [user, searchParams]);
}, [user?.id, targetUserId, searchParams]);

const hasCertificates = userCertificates.length > 0;
const selectedShareUrl = useMemo(() => selected?.stellarUrl ?? "", [selected]);
Expand Down Expand Up @@ -170,8 +172,12 @@ export default function CertificatePage() {
<Link href="/donor#impact-certificates" className="btn btn-outline btn-sm" style={{color:'#fff',borderColor:'rgba(255,255,255,.28)',background:'rgba(255,255,255,.08)',marginBottom:22}}>
<ArrowLeft size={14}/> Back to My Impact
</Link>
<div className="section-label" style={{color:'var(--canopy-light)'}}>MY IMPACT CERTIFICATES</div>
<h1 style={{fontSize:36,fontWeight:800,color:'#fff',marginBottom:12}}>Your verified giving gallery</h1>
<div className="section-label" style={{color:'var(--canopy-light)'}}>
{isOwnView ? "MY IMPACT CERTIFICATES" : "PUBLIC IMPACT CERTIFICATES"}
</div>
<h1 style={{fontSize:36,fontWeight:800,color:'#fff',marginBottom:12}}>
{isOwnView ? "Your verified giving gallery" : "Verified giving gallery"}
</h1>
<p style={{color:'rgba(255,255,255,.68)',fontSize:16,maxWidth:620}}>Every completed milestone creates a blockchain-verifiable certificate you can print, save, or share.</p>
</div>
</div>
Expand Down Expand Up @@ -218,7 +224,9 @@ export default function CertificatePage() {
<div style={{width:72,height:72,borderRadius:18,background:'rgba(74,155,106,.1)',display:'flex',alignItems:'center',justifyContent:'center',margin:'0 auto 18px'}}>
<Award size={34} color="var(--canopy)" strokeWidth={1.7}/>
</div>
<h2 style={{fontSize:24,fontWeight:800,color:'var(--forest)',marginBottom:10}}>You don't have any impact certificates yet</h2>
<h2 style={{fontSize:24,fontWeight:800,color:'var(--forest)',marginBottom:10}}>
{isOwnView ? "You don't have any impact certificates yet" : "No public impact certificates yet"}
</h2>
<p style={{fontSize:15,color:'var(--text2)',lineHeight:1.7,maxWidth:480,margin:'0 auto 24px'}}>Certificates appear here once your supported campaign reaches a verified milestone. Try donating to a campaign to start building your verified impact record.</p>
<div className="flex gap-12" style={{justifyContent:'center',flexWrap:'wrap'}}>
<Link href="/discover" className="btn btn-emerald">
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/app/(marketing)/profile/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ export default function PublicProfilePage() {
<span><Network size={12} /> {shortHash(item.stellar_tx_hash)}</span>
{item.wallet_address ? <span>Wallet: {shortHash(item.wallet_address)}</span> : null}
{item.certificate_id ? (
<Link href={`/certificate?cert=${item.certificate_id}`} className="badge badge-emerald">Certificate</Link>
<Link href={`/certificate?user=${profile.user.id}&cert=${item.certificate_id}`} className="badge badge-emerald">Certificate</Link>
) : null}
</div>
</div>
Expand Down Expand Up @@ -278,7 +278,7 @@ export default function PublicProfilePage() {
<strong>{cert.beneficiary_name}</strong>
<span>{formatXlm(cert.amount)} · {cert.lives_touched} lives touched</span>
<div className="donation-proof-row">
<Link href={`/certificate?cert=${cert.id}`} className="badge badge-emerald">View</Link>
<Link href={`/certificate?user=${profile.user.id}&cert=${cert.id}`} className="badge badge-emerald">View</Link>
<a href={`/api/v1/certificates/${cert.id}/download`} className="badge badge-gray">PDF</a>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

Loading