diff --git a/public/teach.html b/public/teach.html index 8c00bff..2ed5122 100644 --- a/public/teach.html +++ b/public/teach.html @@ -46,11 +46,14 @@

Host Hub

- -
-
-

🌟 Create New Activity

-

Descriptions are encrypted at rest in D1.

+ +
+
+
+

🌟 Create New Activity

+

Descriptions are encrypted at rest in D1.

+
+
@@ -100,55 +103,69 @@

🌟 Create New Activity

- +
- -
-
-

📅 Add Session

-

Location and description are encrypted at rest.

+ +
+
+
+

📅 Add Session

+

Location and description are encrypted at rest.

+
+
-
-
- - + + -
+ +
- - +

New Session Details

- - + + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
-
-
- - -
-
- -
- +
@@ -239,6 +256,7 @@

My Hosted Activities

'
' + '
' + '
' + + '' + 'View' + '
' + ''; @@ -252,17 +270,65 @@

My Hosted Activities

setTimeout(() => el.classList.add('hidden'), 4000); } + let editActivityId = null; + let editSessionId = null; + let currentActivitySessions = []; + let activityEditLoadSeq = 0; + let sessionListLoadSeq = 0; + + async function startEditActivity(id) { + const loadSeq = ++activityEditLoadSeq; + const a = hostedActivities.find(act => act.id === id); + if (!a) return; + + // fetch details and get description + const res = await fetch('/api/activities/' + id, { headers: { Authorization: 'Bearer ' + token } }); + const data = await res.json(); + if (loadSeq !== activityEditLoadSeq) return; + if (!res.ok) { showMsg('act-err', 'Failed to load activity details', true); return; } + + const fullAct = data.activity; + + document.getElementById('a-title').value = fullAct.title; + document.getElementById('a-desc').value = fullAct.description || ''; + document.getElementById('a-type').value = fullAct.type; + document.getElementById('a-format').value = fullAct.format; + document.getElementById('a-schedule').value = fullAct.schedule_type; + document.getElementById('a-tags').value = (fullAct.tags || []).join(', '); + + document.getElementById('act-form-title').innerHTML = '✏️ Edit Activity'; + document.getElementById('btn-submit-act').textContent = 'Update Activity'; + document.getElementById('btn-cancel-act').classList.remove('hidden'); + + editActivityId = id; + document.getElementById('act-section').scrollIntoView({ behavior: 'smooth' }); + } + + function cancelEditActivity() { + document.getElementById('form-activity').reset(); + document.getElementById('act-form-title').innerHTML = '🌟 Create New Activity'; + document.getElementById('btn-submit-act').textContent = 'Create Activity'; + document.getElementById('btn-cancel-act').classList.add('hidden'); + editActivityId = null; + } + document.getElementById('form-activity').addEventListener('submit', async e => { e.preventDefault(); document.getElementById('act-err').classList.add('hidden'); document.getElementById('act-ok').classList.add('hidden'); const btn = e.target.querySelector('button[type=submit]'); - btn.textContent = 'Creating...'; btn.disabled = true; + btn.textContent = editActivityId ? 'Updating...' : 'Creating...'; + btn.disabled = true; + const rawTags = document.getElementById('a-tags').value; const tags = rawTags ? rawTags.split(',').map(t => t.trim()).filter(Boolean) : []; + + const method = editActivityId ? 'PUT' : 'POST'; + const url = editActivityId ? `/api/activities/${editActivityId}` : '/api/activities'; + try { - const res = await fetch('/api/activities', { - method:'POST', headers:{ 'Content-Type':'application/json', Authorization:'Bearer ' + token }, + const res = await fetch(url, { + method, headers:{ 'Content-Type':'application/json', Authorization:'Bearer ' + token }, body: JSON.stringify({ title: document.getElementById('a-title').value.trim(), description: document.getElementById('a-desc').value.trim(), @@ -274,25 +340,106 @@

My Hosted Activities

}); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed'); - showMsg('act-ok', 'Activity created: ' + data.data.title, false); - e.target.reset(); + + showMsg('act-ok', `Activity ${editActivityId ? 'updated' : 'created'} successfully!`, false); + cancelEditActivity(); await loadHostedActivities(); } catch (err) { showMsg('act-err', err.message, true); } - finally { btn.textContent = 'Create Activity'; btn.disabled = false; } + finally { btn.disabled = false; btn.textContent = editActivityId ? 'Update Activity' : 'Create Activity'; } }); + async function onActivitySelect() { + const actId = document.getElementById('s-activity').value; + const loadSeq = ++sessionListLoadSeq; + const listDiv = document.getElementById('existing-sessions'); + const ul = document.getElementById('s-session-list'); + cancelEditSession(); + + if (!actId) { + listDiv.classList.add('hidden'); + return; + } + + const res = await fetch('/api/activities/' + actId, { headers: { Authorization: 'Bearer ' + token } }); + if (!res.ok) { + showMsg('ses-err', 'Failed to load sessions for this activity', true); + listDiv.classList.add('hidden'); + return; + } + const data = await res.json(); + if (loadSeq !== sessionListLoadSeq || document.getElementById('s-activity').value !== actId) return; + currentActivitySessions = data.sessions || []; + + if (currentActivitySessions.length === 0) { + listDiv.classList.add('hidden'); + } else { + listDiv.classList.remove('hidden'); + ul.innerHTML = currentActivitySessions.map(s => { + return `
  • +
    + ${esc(s.title)} +
    ${esc(s.start_time || '')}
    +
    + +
  • `; + }).join(''); + } + } + + function startEditSession(id) { + const s = currentActivitySessions.find(ses => ses.id === id); + if (!s) return; + + document.getElementById('s-title').value = s.title; + document.getElementById('s-desc').value = s.description || ''; + + // browsers require YYYY-MM-DDThh:mm format for datetime-local + const fmt = v => v ? String(v).replace(' ', 'T') : ''; + document.getElementById('s-start').value = fmt(s.start_time); + document.getElementById('s-end').value = fmt(s.end_time); + document.getElementById('s-location').value = s.location || ''; + + document.getElementById('ses-form-title').innerHTML = '✏️ Edit Session'; + document.getElementById('ses-fields-title').textContent = 'Editing Session Details'; + document.getElementById('btn-submit-ses').textContent = 'Update Session'; + document.getElementById('btn-cancel-ses').classList.remove('hidden'); + + editSessionId = id; + } + + function cancelEditSession() { + document.getElementById('s-title').value = ''; + document.getElementById('s-desc').value = ''; + document.getElementById('s-start').value = ''; + document.getElementById('s-end').value = ''; + document.getElementById('s-location').value = ''; + + document.getElementById('ses-form-title').innerHTML = '📅 Add Session'; + document.getElementById('ses-fields-title').textContent = 'New Session Details'; + document.getElementById('btn-submit-ses').textContent = 'Add Session'; + document.getElementById('btn-cancel-ses').classList.add('hidden'); + editSessionId = null; + } + document.getElementById('form-session').addEventListener('submit', async e => { e.preventDefault(); document.getElementById('ses-err').classList.add('hidden'); document.getElementById('ses-ok').classList.add('hidden'); - const btn = e.target.querySelector('button[type=submit]'); + const actId = document.getElementById('s-activity').value; if (!actId) { showMsg('ses-err', 'Please select an activity', true); return; } - btn.textContent = 'Adding...'; btn.disabled = true; + + const btn = e.target.querySelector('button[type=submit]'); + btn.textContent = editSessionId ? 'Updating...' : 'Adding...'; + btn.disabled = true; + const fmt = v => v ? v.replace('T',' ') : ''; + const method = editSessionId ? 'PUT' : 'POST'; + const url = editSessionId ? `/api/sessions/${editSessionId}` : '/api/sessions'; + try { - const res = await fetch('/api/sessions', { - method:'POST', headers:{ 'Content-Type':'application/json', Authorization:'Bearer ' + token }, + const res = await fetch(url, { + method, headers:{ 'Content-Type':'application/json', Authorization:'Bearer ' + token }, body: JSON.stringify({ activity_id: actId, title: document.getElementById('s-title').value.trim(), @@ -304,15 +451,14 @@

    My Hosted Activities

    }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed'); - showMsg('ses-ok', 'Session added successfully!', false); - document.getElementById('s-title').value = ''; - document.getElementById('s-desc').value = ''; - document.getElementById('s-start').value = ''; - document.getElementById('s-end').value = ''; - document.getElementById('s-location').value = ''; + + showMsg('ses-ok', `Session ${editSessionId ? 'updated' : 'added'} successfully!`, false); + cancelEditSession(); await loadHostedActivities(); + document.getElementById('s-activity').value = actId; + await onActivitySelect(); // refresh session list } catch (err) { showMsg('ses-err', err.message, true); } - finally { btn.textContent = 'Add Session'; btn.disabled = false; } + finally { btn.textContent = editSessionId ? 'Update Session' : 'Add Session'; btn.disabled = false; } }); diff --git a/src/worker.py b/src/worker.py index 9656277..cedc938 100644 --- a/src/worker.py +++ b/src/worker.py @@ -1062,6 +1062,197 @@ async def api_add_activity_tags(req, env): return ok(None, "Tags updated") +async def api_update_activity(act_id, req, env): + user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + body, bad_resp = await parse_json_object(req) + if bad_resp: + return bad_resp + + title = body.get("title") + description = body.get("description") + atype = body.get("type") + fmt = body.get("format") + schedule_type = body.get("schedule_type") + + owned = await env.DB.prepare( + "SELECT id FROM activities WHERE id=? AND host_id=?" + ).bind(act_id, user["id"]).first() + if not owned: + return err("Activity not found or access denied", 404) + + updates = [] + params = [] + + if title is not None: + if not isinstance(title, str): + return err("title must be a string", 400) + title = title.strip() + if not title: + return err("title cannot be empty") + updates.append("title=(?)") + params.append(title) + + if description is not None: + if not isinstance(description, str): + return err("description must be a string", 400) + description = description.strip() + enc = env.ENCRYPTION_KEY + updates.append("description=(?)") + params.append(encrypt(description, enc) if description else "") + + if atype is not None: + if not isinstance(atype, str): + return err("type must be a string", 400) + atype = atype.strip() + if atype not in ("course", "meetup", "workshop", "seminar", "other"): + return err("type must be one of: course, meetup, workshop, seminar, other", 400) + updates.append("type=(?)") + params.append(atype) + + if fmt is not None: + if not isinstance(fmt, str): + return err("format must be a string", 400) + fmt = fmt.strip() + if fmt not in ("live", "self_paced", "hybrid"): + return err("format must be one of: live, self_paced, hybrid", 400) + updates.append("format=(?)") + params.append(fmt) + + if schedule_type is not None: + if not isinstance(schedule_type, str): + return err("schedule_type must be a string", 400) + schedule_type = schedule_type.strip() + if schedule_type not in ("one_time", "multi_session", "recurring", "ongoing"): + return err("schedule_type must be one of: one_time, multi_session, recurring, ongoing", 400) + updates.append("schedule_type=(?)") + params.append(schedule_type) + + if updates: + params.append(act_id) + query = "UPDATE activities SET " + ", ".join(updates) + " WHERE id=(?)" + try: + await env.DB.prepare(query).bind(*params).run() + except Exception as e: + capture_exception(e, req, env, "api_update_activity.update_activity") + return err("Failed to update activity, please try again", 500) + elif "tags" not in body: + return ok(None, "No changes provided") + + if "tags" in body: + tags = body.get("tags") + if tags is None: + tags = [] + if not isinstance(tags, list) or any(not isinstance(tag, str) for tag in tags): + return err("tags must be an array of strings", 400) + try: + await env.DB.prepare("DELETE FROM activity_tags WHERE activity_id=?").bind(act_id).run() + except Exception as e: + capture_exception(e, req, env, "api_update_activity.delete_activity_tags") + + for tag_name in tags: + if not isinstance(tag_name, str): + continue + tag_name_clean = tag_name.strip() + if not tag_name_clean: + continue + t_row = await env.DB.prepare("SELECT id FROM tags WHERE name=?").bind(tag_name_clean).first() + if t_row: + tag_id = t_row.id + else: + tag_id = new_id() + try: + await env.DB.prepare("INSERT INTO tags (id,name) VALUES (?,?)").bind(tag_id, tag_name_clean).run() + except Exception as e: + capture_exception(e, req, env, f"api_update_activity.insert_tag: tag_name={tag_name_clean}") + continue + try: + await env.DB.prepare("INSERT OR IGNORE INTO activity_tags (activity_id,tag_id) VALUES (?,?)").bind(act_id, tag_id).run() + except Exception as e: + capture_exception(e, req, env, f"api_update_activity.insert_activity_tag: tag_id={tag_id}") + + return ok(None, "Activity updated") + + +async def api_update_session(ses_id, req, env): + user = verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + body, bad_resp = await parse_json_object(req) + if bad_resp: + return bad_resp + + row = await env.DB.prepare( + "SELECT s.activity_id, a.host_id FROM sessions s JOIN activities a ON s.activity_id = a.id WHERE s.id=?" + ).bind(ses_id).first() + + if not row or row.host_id != user["id"]: + return err("Session not found or access denied", 404) + + title = body.get("title") + description = body.get("description") + start_time = body.get("start_time") + end_time = body.get("end_time") + location = body.get("location") + + updates = [] + params = [] + + if title is not None: + if not isinstance(title, str): + return err("title must be a string", 400) + title = title.strip() + if not title: + return err("title cannot be empty") + updates.append("title=(?)") + params.append(title) + + if description is not None: + if not isinstance(description, str): + return err("description must be a string", 400) + description = description.strip() + enc = env.ENCRYPTION_KEY + updates.append("description=(?)") + params.append(encrypt(description, enc) if description else "") + + if start_time is not None: + if not isinstance(start_time, str): + return err("start_time must be a string", 400) + start_time = start_time.strip() + updates.append("start_time=(?)") + params.append(start_time) + + if end_time is not None: + if not isinstance(end_time, str): + return err("end_time must be a string", 400) + end_time = end_time.strip() + updates.append("end_time=(?)") + params.append(end_time) + + if location is not None: + if not isinstance(location, str): + return err("location must be a string", 400) + location = location.strip() + enc = env.ENCRYPTION_KEY + updates.append("location=(?)") + params.append(encrypt(location, enc) if location else "") + + if updates: + params.append(ses_id) + query = "UPDATE sessions SET " + ", ".join(updates) + " WHERE id=(?)" + try: + await env.DB.prepare(query).bind(*params).run() + except Exception as e: + capture_exception(e, req, env, "api_update_session.update_session") + return err("Failed to update session, please try again", 500) + else: + return ok(None, "No changes provided") + + return ok(None, "Session updated") + async def api_admin_table_counts(req, env): if not _is_basic_auth_valid(req, env): return _unauthorized_basic() @@ -1179,6 +1370,8 @@ async def _dispatch(request, env): m = re.fullmatch(r"/api/activities/([A-Za-z0-9_-]+)", path) if m and method == "GET": return await api_get_activity(m.group(1), request, env) + if m and method == "PUT": + return await api_update_activity(m.group(1), request, env) if path == "/api/join" and method == "POST": return await api_join(request, env) @@ -1188,6 +1381,10 @@ async def _dispatch(request, env): if path == "/api/sessions" and method == "POST": return await api_create_session(request, env) + + m_ses = re.fullmatch(r"/api/sessions/([A-Za-z0-9_-]+)", path) + if m_ses and method == "PUT": + return await api_update_session(m_ses.group(1), request, env) if path == "/api/tags" and method == "GET": return await api_list_tags(request, env)