@@ -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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading certificate...
+
+
+
+
+
+
+
+
Alpha One Labs
+
+
This certifies that
+
+
+
—
+
has successfully completed the activity
+
—
+
+
+
+
Issued on
+
—
+
Certificate ID: —
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Alpha One Labs
+
+
Certificate of Completion
+
This certifies that
+
+
+
+
—
+
has successfully completed the activity
+
—
+
+
+
+
+
+
+
Issued on
+
—
+
Certificate ID: —
+
+
+
+
+
+
+
+
+
+
+
+
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