From 34066a22d87db47a71927f3820ffaa50ddc7d710 Mon Sep 17 00:00:00 2001 From: Ananya Date: Wed, 10 Jun 2026 00:35:45 +0530 Subject: [PATCH 1/4] implement Certificates feature --- migrations/0013_add_certificates.sql | 11 ++ public/activity.html | 103 ++++++++++ public/certificate.html | 278 +++++++++++++++++++++++++++ schema.sql | 9 + src/worker.py | 92 ++++++++- 5 files changed, 489 insertions(+), 4 deletions(-) create mode 100644 migrations/0013_add_certificates.sql create mode 100644 public/certificate.html diff --git a/migrations/0013_add_certificates.sql b/migrations/0013_add_certificates.sql new file mode 100644 index 0000000..db9a269 --- /dev/null +++ b/migrations/0013_add_certificates.sql @@ -0,0 +1,11 @@ +-- Migration 0013: 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 81a9948..50469d6 100644 --- a/public/activity.html +++ b/public/activity.html @@ -216,6 +216,16 @@

+ @@ -418,6 +428,7 @@

Prerequisites

let allActivities = []; let currentActivity = null; let activeTag = null; + let currentEnrollment = null; let currentPage = 1; const activitiesPerPage = 12; const loadedActivityTypes = new Set(); @@ -724,6 +735,94 @@

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; + } + let serverActivityDetailPayloadRead = false; let serverActivityDetailPayload = null; function readServerActivityDetail(activityRef) { @@ -760,6 +859,7 @@

✅ Joined' + watchLink + completeControl + ''; 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) { if (a.status === 'waitlist') { document.getElementById('join-cta').classList.add('hidden'); 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 9d2d2e6..6d352e8 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 ac8a0c9..2787eee 100644 --- a/src/worker.py +++ b/src/worker.py @@ -994,6 +994,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, @@ -1022,6 +1030,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)", @@ -2417,6 +2426,7 @@ async def api_get_activity(activity_ref: str, req, env): "enrollment": { "role": enrollment.role, "status": enrollment.status, + "id": enrollment.id, } if enrollment else None, }) @@ -2500,6 +2510,84 @@ async def api_express_activity_interest(activity_ref: str, req, env): }, "Interest recorded") +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: @@ -7373,10 +7461,6 @@ async def _dispatch(request, env): if path == "/api/notification-preferences" and method == "PATCH": return await api_patch_notification_preferences(request, env) - # Feedback - if path == "/api/feedback" and method == "POST": - return await api_submit_feedback(request, env) - return err("API endpoint not found", 404) return await serve_static(path, env, request) From fba3af7a01d891d89b9aa7e0dd90bfcbd06c8bed Mon Sep 17 00:00:00 2001 From: Ananya Date: Wed, 10 Jun 2026 01:03:29 +0530 Subject: [PATCH 2/4] 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 50469d6..0031774 100644 --- a/public/activity.html +++ b/public/activity.html @@ -800,9 +800,8 @@

Back to Activities - @@ -195,8 +195,12 @@