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-detail.html b/public/activity-detail.html index 21f20fc..a589bb0 100644 --- a/public/activity-detail.html +++ b/public/activity-detail.html @@ -52,6 +52,17 @@

+ +

@@ -164,6 +175,7 @@

Prerequisites

const cartHumanStartedAt = Date.now(); let currentActivity = null; let currentActivityData = null; + let currentEnrollment = null; const typeIcon = { course:'📚', meetup:'🤝', workshop:'🔧', seminar:'🎤', club:'🏛', event:'📅', video:'🎬', study_group:'👥', other:'✨' }; const typeLabel = { course:'Course', meetup:'Meetup', workshop:'Workshop', seminar:'Seminar', club:'Club', event:'Event', video:'Video', study_group:'Study Group', other:'Activity' }; @@ -369,16 +381,117 @@

Prerequisites

}).join(''); } + function resetCertificateSection() { + const card = document.getElementById('certificate-card'); + const button = document.getElementById('btn-certificate'); + const note = document.getElementById('certificate-note'); + const error = document.getElementById('certificate-error'); + if (!card) return; + card.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(data) { + const card = document.getElementById('certificate-card'); + const button = document.getElementById('btn-certificate'); + const note = document.getElementById('certificate-note'); + const error = document.getElementById('certificate-error'); + if (!card) return; + + resetCertificateSection(); + card.classList.remove('hidden'); + + const isCompleted = data.is_completed || (data.enrollment && data.enrollment.status === 'completed'); + + if (isCompleted) { + 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 or mark the activity complete 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'; + return; + } + const targetId = (currentEnrollment && currentEnrollment.id) || (currentActivity && (currentActivity.slug || currentActivity.id)) || ''; + if (!targetId) { + error.textContent = 'Enrollment not found for certificate generation.'; + error.classList.remove('hidden'); + return; + } + + const originalHtml = button.innerHTML; + button.disabled = true; + button.innerHTML = 'Generating...'; + const certWin = window.open('about:blank', '_blank'); + + try { + const res = await fetch(`/api/certificates/generate/${encodeURIComponent(targetId)}`, { + method: 'POST', + headers: { Authorization: 'Bearer ' + token } + }); + const payload = await res.json(); + + if (res.ok) { + if (certWin) certWin.location.href = '/certificate.html?uuid=' + encodeURIComponent(payload.data.uuid); + else window.location.href = '/certificate.html?uuid=' + encodeURIComponent(payload.data.uuid); + button.disabled = false; + button.innerHTML = originalHtml; + return; + } + if (certWin) certWin.close(); + + 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'); + } finally { + if (button.innerHTML && button.innerHTML.includes('Generating...')) { + button.disabled = false; + button.innerHTML = originalHtml; + } + } + } + function renderAction(data) { const a = data.activity; const action = document.getElementById('act-action'); const welcome = document.getElementById('welcome-text'); if (data.is_host) { + currentEnrollment = null; + resetCertificateSection(); action.innerHTML = 'Manage Activity'; welcome.textContent = 'You are the creator of this activity. Use the Manage button to update details and sessions.'; return; } if (data.is_enrolled) { + currentEnrollment = data.enrollment || null; + renderCertificateSection(data); document.getElementById('member-card').classList.remove('hidden'); const enr = data.enrollment || {}; document.getElementById('enr-details').innerHTML = '' + esc(enr.status || 'active') + '✅ You have full access to session details.'; @@ -386,6 +499,8 @@

Prerequisites

welcome.textContent = 'You are participating in this activity. Session locations and details are visible above.'; return; } + currentEnrollment = null; + resetCertificateSection(); if (a.status === 'waitlist') { if (token) { action.innerHTML = data.user_interested diff --git a/public/activity.html b/public/activity.html index 81a9948..b02c5c6 100644 --- a/public/activity.html +++ b/public/activity.html @@ -418,6 +418,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 +725,93 @@

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.ok) { + window.open('/certificate.html?uuid=' + encodeURIComponent(payload.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 +848,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..b5eefae --- /dev/null +++ b/public/certificate.html @@ -0,0 +1,282 @@ + + + + + + Certificate - Alpha One Labs + + + + + + + + + + + + +
+
+ + Back to Activities + + +
+ +
+ Loading certificate... +
+ + +
+ + + + + + + + + + diff --git a/schema.sql b/schema.sql index 9d2d2e6..5b8b84b 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, diff --git a/src/worker.py b/src/worker.py index ac8a0c9..41834a6 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, @@ -2417,6 +2425,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 +2509,116 @@ async def api_express_activity_interest(activity_ref: str, req, env): }, "Interest recorded") +async def api_generate_certificate(request, env, target_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(target_id).first() + + if not enrollment: + enrollment = await env.DB.prepare( + "SELECT e.*, a.host_id FROM enrollments e " + "JOIN activities a ON e.activity_id = a.id " + "WHERE (a.id = ? OR a.slug = ?) AND e.user_id = ?" + ).bind(target_id, target_id, user["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) + + enrollment_id = enrollment.id + + if enrollment.status == "cancelled": + return err("Enrollment is cancelled", 400) + + if enrollment.status != "completed": + comp = await env.DB.prepare( + "SELECT id FROM activity_completions WHERE activity_id=? AND user_id=? LIMIT 1" + ).bind(enrollment.activity_id, enrollment.user_id).first() + if comp: + await env.DB.prepare( + "UPDATE enrollments SET status='completed' WHERE id=?" + ).bind(enrollment_id).run() + else: + 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 ok({ + "uuid": existing.id, + "url": "/certificate.html?uuid=" + existing.id, + }) + + cert_uuid = new_id() + try: + await env.DB.prepare( + "INSERT INTO certificates (id, enrollment_id) VALUES (?, ?)" + ).bind(cert_uuid, enrollment_id).run() + except Exception as exc: + # Handle UNIQUE constraint violation (concurrent duplicate request) + existing_after = await env.DB.prepare( + "SELECT id FROM certificates WHERE enrollment_id = ?" + ).bind(enrollment_id).first() + if existing_after: + return ok({ + "uuid": existing_after.id, + "url": "/certificate.html?uuid=" + existing_after.id, + }) + 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.", + related_id=cert_uuid, + category="system", + ) + + 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: @@ -7231,6 +7350,14 @@ async def _dispatch(request, env): if path == "/api/activities" and method == "POST": return await api_create_activity(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_get = re.fullmatch(r"/api/certificates/([A-Za-z0-9_-]+)", path) + if m_cert_get and method == "GET": + return await api_get_certificate(request, env, m_cert_get.group(1)) + m_complete = re.fullmatch(r"/api/activities/([A-Za-z0-9_-]+)/complete", path) if m_complete and method == "POST": return await api_complete_activity(m_complete.group(1), request, env) @@ -7373,10 +7500,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) @@ -7608,7 +7731,7 @@ async def _create_notification(env, user_id: str, type_: str, title: str, ).bind(new_id(), user_id, type_, await encrypt_aes(title, enc), await encrypt_aes(message, enc), - related_id).run() + related_id or "").run() except Exception as exc: await capture_exception(exc, _env=env, where="_create_notification") return None diff --git a/tests/test_api_activities.py b/tests/test_api_activities.py index 2f639cd..75cfce3 100644 --- a/tests/test_api_activities.py +++ b/tests/test_api_activities.py @@ -407,3 +407,62 @@ async def test_activity_fields_complete(self): "schedule_type", "host_name", "participant_count", "tags", "created_at"): assert field in activity assert activity["participant_count"] == 7 + + +class TestApiCertificates: + def _req(self, method="GET", path="/api/certificates", token=None): + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + return MockRequest(method=method, url=f"http://localhost{path}", headers=headers) + + async def test_generate_certificate_requires_auth(self): + env = make_env() + req = self._req(method="POST", path="/api/certificates/generate/enr-1") + r = await worker.api_generate_certificate(req, env, "enr-1") + assert r.status == 401 + + async def test_generate_certificate_not_found(self): + token = _make_host_token(uid="user-1") + env = make_env(db=MockDB([make_stmt(first=None), make_stmt(first=None)])) + req = self._req(method="POST", path="/api/certificates/generate/enr-1", token=token) + r = await worker.api_generate_certificate(req, env, "enr-1") + assert r.status == 404 + + async def test_generate_certificate_success(self): + token = _make_host_token(uid="user-1") + enr = MockRow(id="enr-1", user_id="user-1", host_id="host-1", status="completed", activity_id="act-1") + env = make_env(db=MockDB([ + make_stmt(first=enr), # fetch enrollment + make_stmt(first=None), # check existing cert + make_stmt(), # insert cert + make_stmt(), # notification + ])) + req = self._req(method="POST", path="/api/certificates/generate/enr-1", token=token) + r = await worker.api_generate_certificate(req, env, "enr-1") + assert r.status == 200 + data = _parse(r) + assert "uuid" in data["data"] + assert "/certificate.html?uuid=" in data["data"]["url"] + + async def test_generate_certificate_by_activity_id_fallback(self): + token = _make_host_token(uid="user-1") + enr = MockRow(id="enr-1", user_id="user-1", host_id="host-1", status="completed", activity_id="act-1") + env = make_env(db=MockDB([ + make_stmt(first=None), # fetch enrollment by enrollment.id (none) + make_stmt(first=enr), # fetch enrollment by activity.id / slug + make_stmt(first=None), # check existing cert + make_stmt(), # insert cert + make_stmt(), # notification + ])) + req = self._req(method="POST", path="/api/certificates/generate/act-1", token=token) + r = await worker.api_generate_certificate(req, env, "act-1") + assert r.status == 200 + data = _parse(r) + assert "uuid" in data["data"] + + async def test_get_certificate_not_found(self): + env = make_env(db=MockDB([make_stmt(first=None)])) + req = self._req(method="GET", path="/api/certificates/cert-uuid") + r = await worker.api_get_certificate(req, env, "cert-uuid") + assert r.status == 404