From 8d78df65b25c869daf175752d6808cb1d0f35211 Mon Sep 17 00:00:00 2001 From: Ananya Date: Wed, 10 Jun 2026 00:35:45 +0530 Subject: [PATCH 1/2] implement Certificates feature --- migrations/0005_add_certificates.sql | 11 ++ public/activity.html | 103 ++++++++++ public/certificate.html | 278 +++++++++++++++++++++++++++ schema.sql | 9 + src/worker.py | 98 ++++++++++ 5 files changed, 499 insertions(+) create mode 100644 migrations/0005_add_certificates.sql create mode 100644 public/certificate.html diff --git a/migrations/0005_add_certificates.sql b/migrations/0005_add_certificates.sql new file mode 100644 index 0000000..657f572 --- /dev/null +++ b/migrations/0005_add_certificates.sql @@ -0,0 +1,11 @@ +-- Migration 0005: Add certificates table + +CREATE TABLE IF NOT EXISTS certificates ( + id TEXT PRIMARY KEY, + enrollment_id TEXT NOT NULL UNIQUE, + issued_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (enrollment_id) REFERENCES enrollments(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_certificates_enrollment ON certificates(enrollment_id); diff --git a/public/activity.html b/public/activity.html index 5dde4c0..9c721ad 100644 --- a/public/activity.html +++ b/public/activity.html @@ -206,6 +206,16 @@

+ @@ -310,6 +320,7 @@

${ }).join(''); } + function resetCertificateSection() { + const section = document.getElementById('certificate-section'); + const button = document.getElementById('btn-certificate'); + const note = document.getElementById('certificate-note'); + const error = document.getElementById('certificate-error'); + section.classList.add('hidden'); + error.classList.add('hidden'); + error.textContent = ''; + note.textContent = ''; + button.disabled = false; + button.onclick = null; + button.className = 'w-full inline-flex items-center justify-center gap-2 font-bold px-5 py-3 rounded-xl transition-all'; + } + + function renderCertificateSection(enrollment) { + const section = document.getElementById('certificate-section'); + const button = document.getElementById('btn-certificate'); + const note = document.getElementById('certificate-note'); + const error = document.getElementById('certificate-error'); + + resetCertificateSection(); + section.classList.remove('hidden'); + + if (enrollment.status === 'completed') { + button.innerHTML = 'Generate Certificate'; + button.classList.add('bg-green-600', 'hover:bg-green-700', 'text-white', 'shadow-md'); + note.textContent = 'Your activity is complete. Generate your certificate and download it as a PDF.'; + button.onclick = generateCertificate; + return; + } + + button.innerHTML = 'Certificate not available yet'; + button.disabled = true; + button.classList.add('bg-gray-300', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-300', 'cursor-not-allowed', 'opacity-60'); + note.textContent = 'Complete all sessions in this activity to unlock your certificate.'; + error.classList.add('hidden'); + } + + async function generateCertificate() { + const error = document.getElementById('certificate-error'); + const button = document.getElementById('btn-certificate'); + error.classList.add('hidden'); + error.textContent = ''; + + if (!token) { + window.location.href = '/login.html'; + return; + } + if (!currentEnrollment || !currentEnrollment.id) { + error.textContent = 'Enrollment not found for certificate generation.'; + error.classList.remove('hidden'); + return; + } + + const originalHtml = button.innerHTML; + button.disabled = true; + button.innerHTML = 'Generating...'; + + try { + const res = await fetch(`/api/certificates/generate/${encodeURIComponent(currentEnrollment.id)}`, { + method: 'POST', + headers: { Authorization: 'Bearer ' + token } + }); + const payload = await res.json(); + + if (res.status === 200 || res.status === 409) { + const data = res.status === 409 ? payload : payload.data; + window.open('/certificate.html?uuid=' + encodeURIComponent(data.uuid), '_blank'); + button.disabled = false; + button.innerHTML = originalHtml; + return; + } + + if (res.status === 400) { + error.textContent = payload.error || 'Something went wrong. Please try again.'; + } else { + error.textContent = 'Something went wrong. Please try again.'; + } + error.classList.remove('hidden'); + } catch (e) { + error.textContent = 'Something went wrong. Please try again.'; + error.classList.remove('hidden'); + } + + button.disabled = false; + button.innerHTML = originalHtml; + } + async function loadActivityDetail(activityId) { + resetCertificateSection(); const headers = token ? { Authorization: 'Bearer ' + token } : {}; const res = await fetch('/api/activities/' + activityId, { headers }); const data = await res.json(); @@ -538,6 +638,7 @@

${ document.getElementById('join-cta').classList.add('hidden'); document.getElementById('member-card').classList.remove('hidden'); const enr = data.enrollment; + currentEnrollment = enr; const rc = { participant:'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300', instructor:'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300', organizer:'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' }; const sc = { active:'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300', cancelled:'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300', completed:'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' }; document.getElementById('enr-details').innerHTML = @@ -547,7 +648,9 @@

${ document.getElementById('act-action').innerHTML = '✅ Joined'; document.getElementById('welcome-card').querySelector('#welcome-text').textContent = 'You are participating in this activity. Session locations and details are visible above.'; + renderCertificateSection(enr); } else { + currentEnrollment = null; if (token) { document.getElementById('join-cta').classList.remove('hidden'); } else { diff --git a/public/certificate.html b/public/certificate.html new file mode 100644 index 0000000..3a13b8d --- /dev/null +++ b/public/certificate.html @@ -0,0 +1,278 @@ + + + + + + Certificate - Alpha One Labs + + + + + + + + + + + + +
+
+ + Back to Activities + + +
+ +
+ Loading certificate... +
+ + +
+ + + + + + + + + + diff --git a/schema.sql b/schema.sql index 2c58c6b..1caa256 100644 --- a/schema.sql +++ b/schema.sql @@ -59,6 +59,14 @@ CREATE TABLE IF NOT EXISTS enrollments ( FOREIGN KEY (user_id) REFERENCES users(id) ); +CREATE TABLE IF NOT EXISTS certificates ( + id TEXT PRIMARY KEY, + enrollment_id TEXT NOT NULL UNIQUE, + issued_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (enrollment_id) REFERENCES enrollments(id) ON DELETE CASCADE +); + -- SESSION ATTENDANCE (optional per-session tracking) CREATE TABLE IF NOT EXISTS session_attendance ( id TEXT PRIMARY KEY, @@ -90,6 +98,7 @@ CREATE TABLE IF NOT EXISTS activity_tags ( CREATE INDEX IF NOT EXISTS idx_activities_host ON activities(host_id); CREATE INDEX IF NOT EXISTS idx_enrollments_activity ON enrollments(activity_id); CREATE INDEX IF NOT EXISTS idx_enrollments_user ON enrollments(user_id); +CREATE INDEX IF NOT EXISTS idx_certificates_enrollment ON certificates(enrollment_id); CREATE INDEX IF NOT EXISTS idx_sessions_activity ON sessions(activity_id); CREATE INDEX IF NOT EXISTS idx_sa_session ON session_attendance(session_id); CREATE INDEX IF NOT EXISTS idx_sa_user ON session_attendance(user_id); diff --git a/src/worker.py b/src/worker.py index f274a07..a043117 100644 --- a/src/worker.py +++ b/src/worker.py @@ -644,6 +644,14 @@ async def send_password_reset_email(to_email: str, _username: str, token: str, e FOREIGN KEY (activity_id) REFERENCES activities(id), FOREIGN KEY (user_id) REFERENCES users(id) )""", + # Certificates + """CREATE TABLE IF NOT EXISTS certificates ( + id TEXT PRIMARY KEY, + enrollment_id TEXT NOT NULL UNIQUE, + issued_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (enrollment_id) REFERENCES enrollments(id) ON DELETE CASCADE + )""", # Session attendance """CREATE TABLE IF NOT EXISTS session_attendance ( id TEXT PRIMARY KEY, @@ -672,6 +680,7 @@ async def send_password_reset_email(to_email: str, _username: str, token: str, e "CREATE INDEX IF NOT EXISTS idx_activities_host ON activities(host_id)", "CREATE INDEX IF NOT EXISTS idx_enrollments_activity ON enrollments(activity_id)", "CREATE INDEX IF NOT EXISTS idx_enrollments_user ON enrollments(user_id)", + "CREATE INDEX IF NOT EXISTS idx_certificates_enrollment ON certificates(enrollment_id)", "CREATE INDEX IF NOT EXISTS idx_sessions_activity ON sessions(activity_id)", "CREATE INDEX IF NOT EXISTS idx_sa_session ON session_attendance(session_id)", "CREATE INDEX IF NOT EXISTS idx_sa_user ON session_attendance(user_id)", @@ -1539,10 +1548,89 @@ async def api_get_activity(act_id: str, req, env): "enrollment": { "role": enrollment.role, "status": enrollment.status, + "id": enrollment.id, } if enrollment else None, }) +async def api_generate_certificate(request, env, enrollment_id): + user = verify_token(request.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + enrollment = await env.DB.prepare( + "SELECT e.*, a.host_id FROM enrollments e " + "JOIN activities a ON e.activity_id = a.id " + "WHERE e.id = ?" + ).bind(enrollment_id).first() + if not enrollment: + return err("Enrollment not found", 404) + + if enrollment.user_id != user["id"] and enrollment.host_id != user["id"]: + return err("Forbidden", 403) + + if enrollment.status != "completed": + return err( + "Enrollment is not completed yet. Finish all sessions to earn your certificate.", + 400, + ) + + existing = await env.DB.prepare( + "SELECT id FROM certificates WHERE enrollment_id = ?" + ).bind(enrollment_id).first() + if existing: + return json_resp({ + "uuid": existing.id, + "url": "/certificate.html?uuid=" + existing.id, + }, status=409) + + cert_uuid = str(uuid.uuid4()) + try: + await env.DB.prepare( + "INSERT INTO certificates (id, enrollment_id) VALUES (?, ?)" + ).bind(cert_uuid, enrollment_id).run() + except Exception as exc: + await capture_exception(exc, request, env, "api_generate_certificate.insert") + return err("Failed to generate certificate", 500) + + await _create_notification( + env, + enrollment.user_id, + "certificate_issued", + "Certificate Issued", + "Your certificate is ready. View and download it from your activity page.", + ) + + return ok({ + "uuid": cert_uuid, + "url": "/certificate.html?uuid=" + cert_uuid, + }) + + +async def api_get_certificate(request, env, cert_uuid): + row = await env.DB.prepare( + "SELECT c.id, c.issued_at, c.enrollment_id, " + "u.name as student_name_enc, " + "a.title as activity_title " + "FROM certificates c " + "JOIN enrollments e ON c.enrollment_id = e.id " + "JOIN users u ON e.user_id = u.id " + "JOIN activities a ON e.activity_id = a.id " + "WHERE c.id = ?" + ).bind(cert_uuid).first() + if not row: + return err("Certificate not found", 404) + + student_name = await decrypt_aes(row.student_name_enc, env.ENCRYPTION_KEY) + return ok({ + "uuid": cert_uuid, + "student_name": student_name, + "activity_title": row.activity_title, + "issued_at": row.issued_at, + "enrollment_id": row.enrollment_id, + }) + + async def api_join(req, env): user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET) if not user: @@ -2708,6 +2796,16 @@ async def _dispatch(request, env): if path == "/api/notification-preferences" and method == "PATCH": return await api_patch_notification_preferences(request, env) + m_cert_gen = re.fullmatch( + r"/api/certificates/generate/([A-Za-z0-9_-]+)", path) + if m_cert_gen and method == "POST": + return await api_generate_certificate( + request, env, m_cert_gen.group(1)) + + m_cert = re.fullmatch(r"/api/certificates/([A-Za-z0-9_-]+)", path) + if m_cert and method == "GET": + return await api_get_certificate(request, env, m_cert.group(1)) + return err("API endpoint not found", 404) return await serve_static(path, env) From 56599d57389f14d91aac74ba575aa589593520a2 Mon Sep 17 00:00:00 2001 From: Ananya Date: Wed, 10 Jun 2026 01:03:29 +0530 Subject: [PATCH 2/2] fixes --- public/activity.html | 5 ++--- public/certificate.html | 10 +++++++--- src/worker.py | 15 ++++++++++++--- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/public/activity.html b/public/activity.html index 9c721ad..60f1b27 100644 --- a/public/activity.html +++ b/public/activity.html @@ -532,9 +532,8 @@

${ }); const payload = await res.json(); - if (res.status === 200 || res.status === 409) { - const data = res.status === 409 ? payload : payload.data; - window.open('/certificate.html?uuid=' + encodeURIComponent(data.uuid), '_blank'); + if (res.ok) { + window.open('/certificate.html?uuid=' + encodeURIComponent(payload.data.uuid), '_blank'); button.disabled = false; button.innerHTML = originalHtml; return; diff --git a/public/certificate.html b/public/certificate.html index 3a13b8d..b5eefae 100644 --- a/public/certificate.html +++ b/public/certificate.html @@ -98,7 +98,7 @@ Back to Activities - @@ -195,8 +195,12 @@