From 9b05d860e179f57ebcf3d3d7678d603affb84adf Mon Sep 17 00:00:00 2001 From: Lakshya Gupta Date: Tue, 21 Apr 2026 00:57:57 +0530 Subject: [PATCH] Add waiting room and courses flows across learner and host journeys. Introduces courses and waiting-room detail pages, teach-course flow wiring, and worker API support for room fulfillment plus host-managed course/session deletion. --- public/course.html | 284 +++++++++++++++++++++++ public/courses.html | 87 +++++++ public/courses.js | 184 +++++++++++++++ public/teach-course.html | 163 +++++++++++++ public/teach-course.js | 308 +++++++++++++++++++++++++ public/waitingroom-detail.html | 100 ++++++++ public/waitingroom-detail.js | 277 ++++++++++++++++++++++ public/waitingroom.html | 163 +++++++++++++ public/waitingroom.js | 403 +++++++++++++++++++++++++++++++++ src/worker.py | 278 ++++++++++++++++++++++- 10 files changed, 2244 insertions(+), 3 deletions(-) create mode 100644 public/course.html create mode 100644 public/courses.html create mode 100644 public/courses.js create mode 100644 public/teach-course.html create mode 100644 public/teach-course.js create mode 100644 public/waitingroom-detail.html create mode 100644 public/waitingroom-detail.js create mode 100644 public/waitingroom.html create mode 100644 public/waitingroom.js diff --git a/public/course.html b/public/course.html new file mode 100644 index 0000000..9696ea5 --- /dev/null +++ b/public/course.html @@ -0,0 +1,284 @@ + + + + + + Activity - Alpha One Labs + + + + + + + + + +
+
+
+ ActivitiesLoading... +
+

Loading...

+

+
+
+
+
+ +
+
+ + + + + +
+ + + + + + + + +
+

Welcome!

+

Select an activity and join to see full details including session locations and descriptions.

+
+
+ +
+
+ + + + + + \ No newline at end of file diff --git a/public/courses.html b/public/courses.html new file mode 100644 index 0000000..8030594 --- /dev/null +++ b/public/courses.html @@ -0,0 +1,87 @@ + + + + + +Courses β€” Alpha One Labs + + + + + + + + + +
+
+ +
+

Browse created courses

+

All published courses appear here. Expand a course to see its sessions.

+
+
+ +
+
+ + + + diff --git a/public/courses.js b/public/courses.js new file mode 100644 index 0000000..3ff45f7 --- /dev/null +++ b/public/courses.js @@ -0,0 +1,184 @@ +function readAuthValue(key) { + const fromSession = sessionStorage.getItem(key); + if (fromSession) return fromSession; + const fromLocal = localStorage.getItem(key); + if (fromLocal) { + sessionStorage.setItem(key, fromLocal); + localStorage.removeItem(key); + } + return fromLocal; +} + +function clearAuth() { + sessionStorage.removeItem('edu_token'); + sessionStorage.removeItem('edu_user'); + localStorage.removeItem('edu_token'); + localStorage.removeItem('edu_user'); +} + +function authHeaders() { + const token = readAuthValue('edu_token'); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function wireUserMenu() { + const navMenu = document.getElementById('nav-user-menu'); + const logoutBtn = document.getElementById('logout-btn'); + if (!navMenu || !logoutBtn) return; + + function setOpen(open) { + navMenu.classList.toggle('open', open); + navMenu.setAttribute('aria-expanded', open ? 'true' : 'false'); + } + + navMenu.addEventListener('click', (event) => { + event.stopPropagation(); + setOpen(!navMenu.classList.contains('open')); + }); + + navMenu.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setOpen(!navMenu.classList.contains('open')); + } else if (event.key === 'Escape') { + setOpen(false); + } + }); + + logoutBtn.addEventListener('click', (event) => { + event.stopPropagation(); + clearAuth(); + window.location.href = '/login.html'; + }); + + document.addEventListener('click', (event) => { + if (!navMenu.contains(event.target)) setOpen(false); + }); +} + +async function fetchCourses() { + const res = await fetch('/api/activities?type=course', { headers: authHeaders() }); + if (!res.ok) return []; + const data = await res.json(); + return data.activities || []; +} + +async function fetchCourseWithSessions(courseId) { + const res = await fetch(`/api/activities/${encodeURIComponent(courseId)}`, { headers: authHeaders() }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || 'Failed to load sessions'); + return { activity: body.activity, sessions: body.sessions || [], is_enrolled: body.is_enrolled, is_host: body.is_host }; +} + +function renderSessions(container, sessions, locked) { + if (!sessions.length) { + container.innerHTML = '
No sessions yet.
'; + return; + } + container.innerHTML = sessions.map((s) => { + const title = escapeHtml(s.title || 'Session'); + const time = [s.start_time, s.end_time].filter(Boolean).join(' – '); + const sub = locked ? 'Join to see location and details' : [s.location, s.description].filter(Boolean).join(' Β· '); + return ` +
+

${title}

+
${escapeHtml(time || sub || '')}
+
+ `; + }).join(''); +} + +function highlightIfNeeded(courseId) { + const params = new URLSearchParams(window.location.search); + const target = params.get('highlight'); + if (!target || target !== String(courseId)) return; + const el = document.getElementById(`course-${courseId}`); + if (!el) return; + el.classList.add('highlight'); + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); +} + +window.addEventListener('DOMContentLoaded', async () => { + const storedProfile = JSON.parse(readAuthValue('edu_user') || 'null'); + const uname = String(storedProfile?.username || '').trim() || '...'; + const avatar = uname.slice(0, 1).toUpperCase() || 'L'; + const navUname = document.getElementById('nav-uname'); + const navAvatar = document.getElementById('nav-avatar'); + if (navUname) navUname.textContent = uname; + if (navAvatar) navAvatar.textContent = avatar; + wireUserMenu(); + + const courses = await fetchCourses(); + const grid = document.getElementById('courses'); + const empty = document.getElementById('empty'); + if (!courses.length) { + empty.style.display = 'block'; + grid.innerHTML = ''; + return; + } + empty.style.display = 'none'; + + grid.innerHTML = courses.map((c) => { + const id = escapeHtml(c.id); + const title = escapeHtml(c.title || 'Untitled course'); + const parts = Number(c.participant_count || 0); + const sess = Number(c.session_count || 0); + const tags = Array.isArray(c.tags) ? c.tags.slice(0, 3) : []; + const tagHtml = tags.map((t) => `#${escapeHtml(t)}`).join(' '); + return ` +
+
+
+

${title}

+
+ πŸ‘₯ ${parts} participants + πŸ—“ ${sess} sessions + ${tagHtml} +
+
+ Open +
+ +
+ +
+ +
+ Sessions +
Loading…
+
+
+ `; + }).join(''); + + courses.forEach((c) => highlightIfNeeded(c.id)); + + grid.addEventListener('click', async (event) => { + const btn = event.target.closest('button[data-action="toggle"]'); + if (!btn) return; + const id = btn.getAttribute('data-course-id'); + const details = grid.querySelector(`details[data-course-id="${CSS.escape(id)}"]`); + if (!details) return; + details.open = !details.open; + if (!details.open) return; + const sessionsEl = document.getElementById(`sessions-${id}`); + if (!sessionsEl || sessionsEl.getAttribute('data-loaded') === '1') return; + try { + const data = await fetchCourseWithSessions(id); + const locked = !(data.is_enrolled || data.is_host); + renderSessions(sessionsEl, data.sessions, locked); + sessionsEl.setAttribute('data-loaded', '1'); + } catch (err) { + sessionsEl.innerHTML = `
${escapeHtml(err.message || 'Failed to load sessions')}
`; + } + }); +}); + diff --git a/public/teach-course.html b/public/teach-course.html new file mode 100644 index 0000000..70db645 --- /dev/null +++ b/public/teach-course.html @@ -0,0 +1,163 @@ + + + + + + Create Course from Waiting Room - Alpha One Labs + + + + + + +
+
+

πŸŽ“ Start Teaching with Alpha One Labs

+

Ready to share your knowledge? Fill out the form below to begin creating your course!

+
+
+

Create Your Course

+
+
+ + +
+
+ + +
+
+ +
+
+ + + + + + + + + +
+ +
+
Provide a detailed description of your course
+
+ + + +
+ +
+ +
+ + Add Another Session Time +
Select preferred time for your course sessions.
+
+
+
Flexible Timing
+ +
+ +
+
+
+
+
+ + + diff --git a/public/teach-course.js b/public/teach-course.js new file mode 100644 index 0000000..a22a5ac --- /dev/null +++ b/public/teach-course.js @@ -0,0 +1,308 @@ +function readAuthValue(key) { + const fromSession = sessionStorage.getItem(key); + if (fromSession) return fromSession; + const fromLocal = localStorage.getItem(key); + if (fromLocal) { + sessionStorage.setItem(key, fromLocal); + localStorage.removeItem(key); + } + return fromLocal; +} + +function writeAuthValue(key, value) { + sessionStorage.setItem(key, value); + localStorage.removeItem(key); +} + +function clearAuth() { + sessionStorage.removeItem('edu_token'); + sessionStorage.removeItem('edu_user'); + localStorage.removeItem('edu_token'); + localStorage.removeItem('edu_user'); +} + +function wireUserMenu() { + const navMenu = document.getElementById('nav-user-menu'); + const dropdown = document.getElementById('nav-user-dropdown'); + const logoutBtn = document.getElementById('logout-btn'); + if (!navMenu || !dropdown || !logoutBtn) return; + + function setOpen(open) { + navMenu.classList.toggle('open', open); + navMenu.setAttribute('aria-expanded', open ? 'true' : 'false'); + } + + navMenu.addEventListener('click', (event) => { + event.stopPropagation(); + setOpen(!navMenu.classList.contains('open')); + }); + + navMenu.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setOpen(!navMenu.classList.contains('open')); + } else if (event.key === 'Escape') { + setOpen(false); + } + }); + + logoutBtn.addEventListener('click', (event) => { + event.stopPropagation(); + clearAuth(); + window.location.href = '/login.html'; + }); + + document.addEventListener('click', (event) => { + if (!navMenu.contains(event.target)) setOpen(false); + }); +} + +function authHeaders() { + const token = readAuthValue('edu_token'); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +async function fetchRoom(roomId) { + const res = await fetch(`/api/waitingroom/${encodeURIComponent(roomId)}`, { headers: authHeaders() }); + if (!res.ok) throw new Error('Waiting room not found'); + const data = await res.json(); + return data.room; +} + +async function createCourse(payload) { + const res = await fetch('/api/activities', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify(payload) + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || 'Failed to create course'); + return body.data; +} + +async function createSession(payload) { + const res = await fetch('/api/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify(payload) + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || 'Failed to create session'); + return body.data; +} + +async function fulfillRoom(roomId, actor, courseId, courseTitle) { + const res = await fetch('/api/waitingroom/fulfill', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ roomId, actor, courseId, courseTitle, fulfilledAt: Date.now() }) + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || 'Failed to mark waiting room fulfilled'); + return body; +} + +function fmtDate(v) { + return v ? String(v).replace('T', ' ') : ''; +} + +function replaceSelection(textarea, nextText, cursorOffset = 0) { + const start = textarea.selectionStart ?? textarea.value.length; + const end = textarea.selectionEnd ?? textarea.value.length; + const before = textarea.value.slice(0, start); + const selected = textarea.value.slice(start, end); + const after = textarea.value.slice(end); + const inserted = typeof nextText === 'function' ? nextText(selected) : nextText; + textarea.value = before + inserted + after; + const caret = before.length + inserted.length + cursorOffset; + textarea.focus(); + textarea.setSelectionRange(caret, caret); +} + +function lineifySelection(textarea, formatter) { + const start = textarea.selectionStart ?? 0; + const end = textarea.selectionEnd ?? 0; + const value = textarea.value; + const lineStart = value.lastIndexOf('\n', start - 1) + 1; + const lineEndRaw = value.indexOf('\n', end); + const lineEnd = lineEndRaw === -1 ? value.length : lineEndRaw; + const selectedLines = value.slice(lineStart, lineEnd).split('\n'); + const formatted = selectedLines.map(formatter).join('\n'); + textarea.value = value.slice(0, lineStart) + formatted + value.slice(lineEnd); + textarea.focus(); + textarea.setSelectionRange(lineStart, lineStart + formatted.length); +} + +function wireDescriptionToolbar() { + const toolbar = document.querySelector('.toolbar'); + const textarea = document.getElementById('description'); + const errEl = document.getElementById('err-msg'); + if (!toolbar || !textarea) return; + + toolbar.addEventListener('click', (event) => { + const btn = event.target.closest('button[data-action]'); + if (!btn) return; + const action = btn.getAttribute('data-action'); + if (!action) return; + errEl.textContent = ''; + + if (action === 'bold') { + replaceSelection(textarea, (sel) => `**${sel || 'bold text'}**`, selOrFallbackOffset(textarea, 2, 'bold text')); + return; + } + if (action === 'italic') { + replaceSelection(textarea, (sel) => `*${sel || 'italic text'}*`, selOrFallbackOffset(textarea, 1, 'italic text')); + return; + } + if (action === 'heading') { + lineifySelection(textarea, (line) => line.startsWith('# ') ? line : `# ${line || 'Heading'}`); + return; + } + if (action === 'quote') { + lineifySelection(textarea, (line) => line.startsWith('> ') ? line : `> ${line || 'Quoted text'}`); + return; + } + if (action === 'ul') { + lineifySelection(textarea, (line) => line.startsWith('- ') ? line : `- ${line || 'List item'}`); + return; + } + if (action === 'ol') { + let index = 1; + lineifySelection(textarea, (line) => { + const out = `${index}. ${line || 'List item'}`; + index += 1; + return out; + }); + return; + } + if (action === 'link') { + const selected = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd); + const text = selected || 'link text'; + replaceSelection(textarea, `[${text}](https://example.com)`); + return; + } + if (action === 'separator') { + replaceSelection(textarea, '\n---\n'); + return; + } + if (action === 'help') { + errEl.textContent = 'Formatting supports Markdown: **bold**, *italic*, # heading, > quote, lists, and [links](url).'; + } + }); +} + +function selOrFallbackOffset(textarea, markerLen, fallback) { + const hasSelection = (textarea.selectionEnd ?? 0) > (textarea.selectionStart ?? 0); + return hasSelection ? 0 : -(fallback.length + markerLen); +} + +window.addEventListener('DOMContentLoaded', async () => { + wireDescriptionToolbar(); + const token = readAuthValue('edu_token'); + if (!token) { + window.location.href = '/login.html'; + return; + } + + const storedProfile = JSON.parse(readAuthValue('edu_user') || 'null'); + const currentUser = String(storedProfile?.username || '').trim(); + document.getElementById('nav-uname').textContent = currentUser || '...'; + document.getElementById('nav-avatar').textContent = (currentUser || 'L').slice(0, 1).toUpperCase(); + wireUserMenu(); + + const params = new URLSearchParams(window.location.search); + const waitingRoomId = params.get('waitingRoomId'); + if (!waitingRoomId) { + document.getElementById('err-msg').textContent = 'Missing waiting room id.'; + return; + } + + let room = null; + try { + const dashboardRes = await fetch('/api/dashboard', { headers: authHeaders() }); + if (!dashboardRes.ok) { + clearAuth(); + window.location.href = '/login.html'; + return; + } + const dashboard = await dashboardRes.json(); + const apiUser = dashboard?.user || {}; + const profile = { + ...(storedProfile || {}), + id: String(apiUser.id || storedProfile?.id || '').trim(), + username: String(apiUser.username || storedProfile?.username || '').trim() + }; + writeAuthValue('edu_user', JSON.stringify(profile)); + document.getElementById('nav-uname').textContent = profile.username || '...'; + document.getElementById('nav-avatar').textContent = (profile.username || 'L').slice(0, 1).toUpperCase(); + + room = await fetchRoom(waitingRoomId); + const isCreator = String(room.creator || '').toLowerCase() === String(profile.username || '').toLowerCase(); + if (!isCreator) { + document.getElementById('err-msg').textContent = 'Only the waiting room creator can teach this course.'; + document.getElementById('submit-btn').disabled = true; + return; + } + if (room.fulfilled) { + document.getElementById('ok-msg').textContent = 'This waiting room is already fulfilled.'; + document.getElementById('submit-btn').disabled = true; + return; + } + + document.getElementById('title').value = room.title || ''; + document.getElementById('description').value = room.desc || ''; + document.getElementById('subject').value = room.subject || ''; + document.getElementById('tags').value = Array.isArray(room.tags) ? room.tags.join(', ') : ''; + } catch (err) { + document.getElementById('err-msg').textContent = err.message || 'Failed to load waiting room.'; + return; + } + + document.getElementById('course-form').addEventListener('submit', async (event) => { + event.preventDefault(); + const errEl = document.getElementById('err-msg'); + const okEl = document.getElementById('ok-msg'); + const btn = document.getElementById('submit-btn'); + errEl.textContent = ''; + okEl.textContent = ''; + btn.disabled = true; + btn.textContent = 'Publishing...'; + + try { + const title = document.getElementById('title').value.trim(); + if (!title) throw new Error('Course title is required'); + const description = document.getElementById('description').value.trim(); + const tags = document.getElementById('tags').value.split(',').map((t) => t.trim()).filter(Boolean); + + const course = await createCourse({ + title, + description, + type: 'course', + format: 'live', + schedule_type: 'multi_session', + tags + }); + + const startTime = fmtDate(document.getElementById('start-time').value); + const endTime = fmtDate(document.getElementById('end-time').value); + if (startTime || endTime) { + await createSession({ + activity_id: course.id, + title: `${title} - Session 1`, + description: 'Session scheduled from waiting room flow', + start_time: startTime, + end_time: endTime, + location: '' + }); + } + + await fulfillRoom(waitingRoomId, document.getElementById('nav-uname').textContent.trim(), course.id, course.title || title); + okEl.textContent = 'Course published and waiting room marked fulfilled.'; + window.location.href = `/courses.html?highlight=${encodeURIComponent(course.id)}`; + } catch (err) { + errEl.textContent = err.message || 'Failed to publish course'; + btn.disabled = false; + btn.textContent = 'Publish Course'; + } + }); +}); diff --git a/public/waitingroom-detail.html b/public/waitingroom-detail.html new file mode 100644 index 0000000..7e92569 --- /dev/null +++ b/public/waitingroom-detail.html @@ -0,0 +1,100 @@ + + + + + +Waiting Room Details β€” Alpha One Labs + + + + + +
+ ← Back to Waiting Rooms +
+
+
+

Loading...

+
+
+
+ Open +
+ +
+
+

Request Details

+
Subject
+
-
+
Topics
+
-
+
Created By
+
-
+
+
+
+

Participants

+ 0 +
+
+
+
+ +
+
+
+
+ + + + diff --git a/public/waitingroom-detail.js b/public/waitingroom-detail.js new file mode 100644 index 0000000..58b5b0c --- /dev/null +++ b/public/waitingroom-detail.js @@ -0,0 +1,277 @@ +function readAuthValue(key) { + const fromSession = sessionStorage.getItem(key); + if (fromSession) return fromSession; + const fromLocal = localStorage.getItem(key); + if (fromLocal) { + sessionStorage.setItem(key, fromLocal); + localStorage.removeItem(key); + } + return fromLocal; +} + +function writeAuthValue(key, value) { + sessionStorage.setItem(key, value); + localStorage.removeItem(key); +} + +function clearAuth() { + sessionStorage.removeItem('edu_token'); + sessionStorage.removeItem('edu_user'); + localStorage.removeItem('edu_token'); + localStorage.removeItem('edu_user'); +} + +function authHeaders() { + const token = readAuthValue('edu_token'); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +function getUserFromToken() { + const token = readAuthValue('edu_token'); + if (!token || !token.includes('.')) return null; + try { + const payload = token.split('.')[0]; + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4); + const decoded = atob(padded); + const user = JSON.parse(decoded); + if (!user || typeof user !== 'object') return null; + return { + id: String(user.id || '').trim(), + username: String(user.username || '').trim() + }; + } catch { + return null; + } +} + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function normalizeParticipant(participant) { + if (participant && typeof participant === 'object') { + return { + id: String(participant.id || '').trim(), + name: String(participant.name || 'anonymous').trim() || 'anonymous' + }; + } + const name = String(participant || 'anonymous').trim() || 'anonymous'; + return { id: name.toLowerCase(), name }; +} + +async function fetchRoom(roomId) { + const res = await fetch(`/api/waitingroom/${encodeURIComponent(roomId)}`, { headers: authHeaders() }); + if (!res.ok) throw new Error('Room not found'); + const data = await res.json(); + return data.room; +} + +async function joinRoom(roomId, name, participantId) { + const res = await fetch('/api/waitingroom/join', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ roomId, name, participantId }) + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || 'Failed to join room'); + return body; +} + +async function leaveRoom(roomId, name, participantId) { + const res = await fetch('/api/waitingroom/leave', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ roomId, name, participantId }) + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || 'Failed to leave room'); + return body; +} + +async function deleteRoom(roomId, actor) { + const res = await fetch('/api/waitingroom/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ roomId, actor }) + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || 'Failed to delete room'); + return body; +} + +function getParticipantId(profile, currentUser) { + if (profile?.id) return String(profile.id); + const userKey = String(currentUser || 'guest').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '_') || 'guest'; + const key = `waitingroom_participant_id_${userKey}`; + let id = localStorage.getItem(key); + if (!id) { + id = `p_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + localStorage.setItem(key, id); + } + return id; +} + +function renderRoom(room, currentUser, participantId) { + const participants = (Array.isArray(room.participants) ? room.participants : []).map(normalizeParticipant); + const isJoined = participants.some((p) => p.id === participantId); + const isCreator = String(room.creator || '').toLowerCase() === String(currentUser).toLowerCase(); + const isFulfilled = Boolean(room.fulfilled); + document.getElementById('room-title').textContent = room.title || 'Untitled'; + document.getElementById('room-desc').textContent = room.desc || ''; + document.getElementById('room-status').innerHTML = ` ${isFulfilled ? 'Fulfilled' : 'Open'}`; + document.getElementById('room-subject').textContent = room.subject || 'General'; + document.getElementById('room-creator').textContent = room.creator || 'anonymous'; + document.getElementById('participant-count').textContent = String(participants.length); + document.getElementById('room-linked-course').innerHTML = (isFulfilled && room.courseId) + ? `Linked Course: ${escapeHtml(room.courseTitle || room.courseId)}` + : ''; + document.getElementById('room-tags').innerHTML = (Array.isArray(room.tags) && room.tags.length) + ? room.tags.map((tag) => `${escapeHtml(tag)}`).join(' ') + : '-'; + + const list = document.getElementById('participant-list'); + if (!participants.length) { + list.innerHTML = '
No participants yet.
'; + } else { + list.innerHTML = participants.map((p) => { + const initial = escapeHtml((p.name || 'a').slice(0, 1)); + const creatorPill = String(room.creator || '').toLowerCase() === String(p.name || '').toLowerCase() + ? 'Creator' + : ''; + return ` +
+ ${initial}${escapeHtml(p.name)} + ${creatorPill} +
+ `; + }).join(''); + } + + const actions = document.getElementById('actions'); + const buttons = []; + if (!isFulfilled) { + if (isJoined) { + buttons.push(''); + } else { + buttons.push(''); + } + } + if (isCreator && !isFulfilled) { + buttons.push(''); + buttons.push(''); + } + actions.innerHTML = buttons.join(''); +} + +window.addEventListener('DOMContentLoaded', async () => { + const token = readAuthValue('edu_token'); + if (!token) { + window.location.href = '/login.html'; + return; + } + + const params = new URLSearchParams(window.location.search); + const roomId = params.get('id'); + if (!roomId) { + document.getElementById('error').textContent = 'Missing room id.'; + return; + } + + let tokenUser = getUserFromToken() || {}; + let profile = JSON.parse(readAuthValue('edu_user') || 'null') || {}; + + try { + const res = await fetch('/api/dashboard', { headers: authHeaders() }); + if (!res.ok) { + clearAuth(); + window.location.href = '/login.html'; + return; + } + const dashboard = await res.json(); + const apiUser = dashboard?.user || {}; + profile = { + ...profile, + id: String(apiUser.id || tokenUser.id || profile.id || '').trim(), + username: String(apiUser.username || tokenUser.username || profile.username || '').trim() + }; + writeAuthValue('edu_user', JSON.stringify(profile)); + } catch { + clearAuth(); + window.location.href = '/login.html'; + return; + } + + const currentUser = String(profile.username || tokenUser.username || 'guest').trim(); + const currentParticipantId = getParticipantId({ id: profile.id || tokenUser.id || '' }, currentUser); + document.getElementById('nav-user').textContent = currentUser; + + let room = null; + let isRefreshing = false; + async function refresh() { + if (isRefreshing) return; + isRefreshing = true; + try { + room = await fetchRoom(roomId); + renderRoom(room, currentUser, currentParticipantId); + document.getElementById('error').textContent = ''; + } finally { + isRefreshing = false; + } + } + + try { + await refresh(); + } catch (err) { + document.getElementById('error').textContent = err.message || 'Failed to load room.'; + return; + } + + document.getElementById('actions').addEventListener('click', async (event) => { + const btn = event.target.closest('button[data-action]'); + if (!btn) return; + const action = btn.getAttribute('data-action'); + try { + if (action === 'join') { + await joinRoom(roomId, currentUser, currentParticipantId); + } else if (action === 'leave') { + await leaveRoom(roomId, currentUser, currentParticipantId); + } else if (action === 'teach') { + window.location.href = `/teach-course.html?waitingRoomId=${encodeURIComponent(roomId)}`; + return; + } else if (action === 'delete') { + await deleteRoom(roomId, currentUser); + window.location.href = '/waitingroom.html'; + return; + } + await refresh(); + } catch (err) { + document.getElementById('error').textContent = err.message || 'Action failed.'; + } + }); + + // Lightweight real-time sync without websockets: poll every 3s. + const refreshInterval = setInterval(async () => { + try { + await refresh(); + } catch (err) { + document.getElementById('error').textContent = err.message || 'Sync failed.'; + } + }, 3000); + + document.addEventListener('visibilitychange', async () => { + if (!document.hidden) { + try { + await refresh(); + } catch { + // Keep UI stable; periodic polling will retry. + } + } + }); + + window.addEventListener('beforeunload', () => clearInterval(refreshInterval)); +}); diff --git a/public/waitingroom.html b/public/waitingroom.html new file mode 100644 index 0000000..ba34083 --- /dev/null +++ b/public/waitingroom.html @@ -0,0 +1,163 @@ + + + + + +Waiting Rooms β€” Alpha One Labs + + + + + + + + + +
+
+ + + +
+

What are Waiting Rooms?

+

Waiting rooms allow you to express interest in subjects and topics you want to learn. Join existing waiting rooms or create your own to find others interested in the same topics. Teachers can see these waiting rooms and create courses based on popular demand.

+
+ +
+

Open Waiting Rooms

+ 8 +
+ +
+ +
+
+ + + + + + + +
+ + + + diff --git a/public/waitingroom.js b/public/waitingroom.js new file mode 100644 index 0000000..9a1a111 --- /dev/null +++ b/public/waitingroom.js @@ -0,0 +1,403 @@ +async function fetchRooms() { + const res = await fetch('/api/waitingrooms', { headers: authHeaders() }); + if (!res.ok) return []; + const data = await res.json(); + return data.rooms || []; +} + +function roomDetailUrl(roomId) { + return `/waitingroom-detail.html?id=${encodeURIComponent(String(roomId || ''))}`; +} + +async function createRoom(room) { + const res = await fetch('/api/waitingroom/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify(room) + }); + if (!res.ok) throw new Error('Failed to create room'); + return await res.json(); +} + +async function joinRoom(roomId, name, participantId) { + const res = await fetch('/api/waitingroom/join', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ roomId, name, participantId }) + }); + if (!res.ok) throw new Error('Failed to join room'); + return await res.json(); +} + +async function leaveRoom(roomId, name, participantId) { + const res = await fetch('/api/waitingroom/leave', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ roomId, name, participantId }) + }); + if (!res.ok) throw new Error('Failed to leave room'); + return await res.json(); +} + +async function deleteRoom(roomId, actor) { + const res = await fetch('/api/waitingroom/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ roomId, actor }) + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Failed to delete room'); + } + return await res.json(); +} + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function readAuthValue(key) { + const fromSession = sessionStorage.getItem(key); + if (fromSession) return fromSession; + const fromLocal = localStorage.getItem(key); + if (fromLocal) { + sessionStorage.setItem(key, fromLocal); + localStorage.removeItem(key); + } + return fromLocal; +} + +function writeAuthValue(key, value) { + sessionStorage.setItem(key, value); + localStorage.removeItem(key); +} + +function clearAuth() { + sessionStorage.removeItem('edu_token'); + sessionStorage.removeItem('edu_user'); + localStorage.removeItem('edu_token'); + localStorage.removeItem('edu_user'); +} + +function authHeaders() { + const token = readAuthValue('edu_token'); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +function getUserFromToken() { + const token = readAuthValue('edu_token'); + if (!token || !token.includes('.')) return null; + try { + const payload = token.split('.')[0]; + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4); + const decoded = atob(padded); + const user = JSON.parse(decoded); + if (!user || typeof user !== 'object') return null; + return { + id: String(user.id || '').trim(), + username: String(user.username || '').trim(), + role: String(user.role || '').trim() + }; + } catch { + return null; + } +} + +function getParticipantId(profile, currentUser) { + if (profile?.id) return String(profile.id); + const userKey = String(currentUser || 'guest').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '_') || 'guest'; + const key = `waitingroom_participant_id_${userKey}`; + let id = localStorage.getItem(key); + if (!id) { + id = `p_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + localStorage.setItem(key, id); + } + return id; +} + +function normalizeParticipant(participant) { + if (participant && typeof participant === 'object') { + return { + id: String(participant.id || '').trim(), + name: String(participant.name || 'anonymous').trim() || 'anonymous' + }; + } + const name = String(participant || 'anonymous').trim() || 'anonymous'; + return { id: name.toLowerCase(), name }; +} + +function timeAgo(ts) { + const s = Math.floor((Date.now() - Number(ts || 0)) / 1000); + if (!Number.isFinite(s) || s <= 0) return 'just now'; + if (s < 60) return 'just now'; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + return `${Math.floor(s / 86400)}d ago`; +} + +function renderRooms(rooms, currentUser, currentParticipantId) { + const grid = document.getElementById('rooms-grid'); + const roomCount = document.getElementById('room-count'); + const youLabel = document.getElementById('you-label'); + const tabCount = document.getElementById('tab-count'); + roomCount.textContent = String(rooms.length); + if (youLabel) youLabel.textContent = currentUser; + + if (!rooms.length) { + grid.innerHTML = '
No waiting rooms yet
Create the first learning request to get started.
'; + if (tabCount) tabCount.textContent = '0'; + return; + } + + let totalParticipants = 0; + grid.innerHTML = rooms.map((room) => { + const tags = Array.isArray(room.tags) ? room.tags : []; + const participantList = (Array.isArray(room.participants) ? room.participants : []).map(normalizeParticipant); + const participants = participantList.length; + totalParticipants += participants; + const isFulfilled = Boolean(room.fulfilled); + const isJoined = participantList.some((p) => String(p.id) === String(currentParticipantId)); + const isCreator = String(room.creator || '').toLowerCase() === String(currentUser).toLowerCase(); + const tagsHtml = tags.length + ? `
${tags.map((t) => `${escapeHtml(t)}`).join('')}
` + : ''; + const participantsHtml = participantList.length + ? `
${participantList.map((p) => `${escapeHtml(p.name)}`).join('')}
` + : ''; + + return ` +
+
+ ${escapeHtml(room.subject || 'General')} Β· ${participants} interested + ${isFulfilled ? 'Fulfilled' : 'Open'} +
+
${escapeHtml(room.title || 'Untitled')}
+ ${room.desc ? `
${escapeHtml(room.desc)}
` : ''} + ${isFulfilled && room.courseId ? `` : ''} + ${tagsHtml} + ${participantsHtml} + +
+ `; + }).join(''); + if (tabCount) tabCount.textContent = String(totalParticipants); +} + +function toast(message) { + const el = document.getElementById('toast'); + if (!el) return; + el.textContent = message; + el.classList.add('show'); + setTimeout(() => el.classList.remove('show'), 2500); +} + +function wireModal(onCreate) { + const modal = document.getElementById('modal'); + const createBtn = document.querySelector('.btn-create'); + const cancelBtn = document.querySelector('.btn-cancel'); + const submitBtn = document.querySelector('.btn-submit'); + + const open = () => modal.classList.add('open'); + const close = () => modal.classList.remove('open'); + + createBtn?.addEventListener('click', open); + cancelBtn?.addEventListener('click', close); + modal?.addEventListener('click', (event) => { + if (event.target === modal) close(); + }); + + submitBtn?.addEventListener('click', async () => { + const title = document.getElementById('f-title').value.trim(); + if (!title) { + toast('Topic title is required'); + return; + } + const payload = { + title, + subject: document.getElementById('f-subject').value.trim(), + desc: document.getElementById('f-desc').value.trim(), + tags: document.getElementById('f-tags').value.split(',').map((s) => s.trim()).filter(Boolean), + creator: document.getElementById('nav-uname')?.textContent?.trim() || 'anonymous', + createdAt: Date.now() + }; + await onCreate(payload); + close(); + document.getElementById('f-title').value = ''; + document.getElementById('f-desc').value = ''; + document.getElementById('f-tags').value = ''; + }); +} + +function wireUserMenu() { + const navMenu = document.getElementById('nav-user-menu'); + const dropdown = document.getElementById('nav-user-dropdown'); + const logoutBtn = document.getElementById('logout-btn'); + if (!navMenu || !dropdown || !logoutBtn) return; + + function setOpen(open) { + navMenu.classList.toggle('open', open); + navMenu.setAttribute('aria-expanded', open ? 'true' : 'false'); + } + + navMenu.addEventListener('click', (event) => { + event.stopPropagation(); + setOpen(!navMenu.classList.contains('open')); + }); + + navMenu.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setOpen(!navMenu.classList.contains('open')); + } else if (event.key === 'Escape') { + setOpen(false); + } + }); + + logoutBtn.addEventListener('click', (event) => { + event.stopPropagation(); + clearAuth(); + window.location.href = '/login.html'; + }); + + document.addEventListener('click', (event) => { + if (!navMenu.contains(event.target)) setOpen(false); + }); +} + +window.addEventListener('DOMContentLoaded', async () => { + const token = readAuthValue('edu_token'); + if (!token) { + window.location.href = '/login.html'; + return; + } + + const tokenUser = getUserFromToken(); + const storedProfile = JSON.parse(readAuthValue('edu_user') || 'null'); + let profile = (tokenUser?.id || tokenUser?.username) + ? { ...(storedProfile || {}), id: tokenUser.id || storedProfile?.id || '', username: tokenUser.username || storedProfile?.username || '' } + : storedProfile; + + // Final source of truth: backend-authenticated user for this token. + try { + const res = await fetch('/api/dashboard', { headers: authHeaders() }); + if (!res.ok) { + clearAuth(); + window.location.href = '/login.html'; + return; + } + const dashboard = await res.json(); + const apiUser = dashboard?.user || {}; + if (apiUser?.id || apiUser?.username) { + profile = { + ...(profile || {}), + id: String(apiUser.id || profile?.id || '').trim(), + username: String(apiUser.username || profile?.username || '').trim() + }; + } + } catch { + clearAuth(); + window.location.href = '/login.html'; + return; + } + + if (profile) { + writeAuthValue('edu_user', JSON.stringify(profile)); + } + + const currentUser = (profile?.username || tokenUser?.username || document.getElementById('nav-uname')?.textContent || 'guest').trim(); + const currentParticipantId = getParticipantId({ id: profile?.id || tokenUser?.id || '' }, currentUser); + document.getElementById('nav-uname').textContent = currentUser; + document.getElementById('nav-avatar').textContent = currentUser.slice(0, 1).toUpperCase(); + wireUserMenu(); + + let rooms = await fetchRooms(); + renderRooms(rooms, currentUser, currentParticipantId); + + // Lightweight real-time sync without websockets: poll every 3s. + let isRefreshing = false; + async function refreshRooms() { + if (isRefreshing) return; + isRefreshing = true; + try { + rooms = await fetchRooms(); + renderRooms(rooms, currentUser, currentParticipantId); + } finally { + isRefreshing = false; + } + } + const refreshInterval = setInterval(refreshRooms, 3000); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) refreshRooms(); + }); + window.addEventListener('beforeunload', () => clearInterval(refreshInterval)); + + wireModal(async (payload) => { + try { + payload.creatorId = profile?.id || ''; + const created = await createRoom(payload); + rooms = [created, ...rooms]; + renderRooms(rooms, currentUser, currentParticipantId); + toast('Waiting room created'); + } catch { + toast('Failed to create waiting room'); + } + }); + + document.getElementById('rooms-grid')?.addEventListener('click', async (event) => { + const btn = event.target.closest('button[data-action]'); + if (!btn) { + const card = event.target.closest('.room-card[data-room-id]'); + if (card) { + const roomId = card.getAttribute('data-room-id'); + if (roomId) window.location.href = roomDetailUrl(roomId); + } + return; + } + const action = btn.getAttribute('data-action'); + const roomId = btn.getAttribute('data-room-id'); + if (!action || !roomId) return; + + try { + if (action === 'join') { + const updated = await joinRoom(roomId, currentUser, currentParticipantId); + rooms = rooms.map((room) => (room.id === roomId ? updated : room)); + toast('Joined waiting room'); + } else if (action === 'leave') { + const updated = await leaveRoom(roomId, currentUser, currentParticipantId); + rooms = rooms.map((room) => (room.id === roomId ? updated : room)); + toast('Left waiting room'); + } else if (action === 'delete') { + await deleteRoom(roomId, currentUser); + rooms = rooms.filter((room) => room.id !== roomId); + toast('Deleted waiting room'); + } + renderRooms(rooms, currentUser, currentParticipantId); + } catch (err) { + toast(err?.message || 'Action failed'); + } + }); + + document.getElementById('rooms-grid')?.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + if (event.target.closest('button[data-action]')) return; + const card = event.target.closest('.room-card[data-room-id]'); + if (!card) return; + event.preventDefault(); + const roomId = card.getAttribute('data-room-id'); + if (roomId) window.location.href = roomDetailUrl(roomId); + }); +}); diff --git a/src/worker.py b/src/worker.py index 25001e3..0046337 100644 --- a/src/worker.py +++ b/src/worker.py @@ -40,15 +40,204 @@ import re import traceback from types import SimpleNamespace +import uuid +from workers import DurableObject, Response +class WaitingRoomDO(DurableObject): + def __init__(self, state, env): + super().__init__(state, env) + self.state = state + self.env = env + self.rooms = None # lazy load durable state + + async def on_fetch(self, request): + url = urlparse(request.url) + method = request.method.upper() + path = url.path + auth_user = verify_token( + request.headers.get("Authorization") or "", + getattr(self.env, "JWT_SECRET", "") or "", + ) + if self.rooms is None: + stored_rooms = await self.state.storage.get("rooms") + if isinstance(stored_rooms, str) and stored_rooms: + self.rooms = json.loads(stored_rooms) + else: + self.rooms = {} + + def _normalize_participants(raw): + items = raw if isinstance(raw, list) else [] + out = [] + for p in items: + if isinstance(p, dict): + pid = str(p.get("id") or "").strip() + name = str(p.get("name") or "").strip() or "anonymous" + if not pid: + pid = name.lower() + out.append({"id": pid, "name": name}) + elif isinstance(p, str): + name = p.strip() or "anonymous" + out.append({"id": name.lower(), "name": name}) + return out + + m_room = re.fullmatch(r"/api/waitingroom/([A-Za-z0-9_-]+)", path) + if m_room and method == "GET": + room_id = m_room.group(1) + room = self.rooms.get(room_id) + if not room: + return Response( + json.dumps({"error": "Room not found"}), + status=404, + headers={"Content-Type": "application/json"}, + ) + return Response( + json.dumps({"room": room}), + headers={"Content-Type": "application/json"}, + ) + + if path.endswith('/api/waitingrooms') and method == 'GET': + # List all rooms + return Response(json.dumps({"rooms": list(self.rooms.values())}), headers={"Content-Type": "application/json"}) + if path.endswith('/api/waitingroom/create') and method == 'POST': + data = await request.json() + room_id = 'r_' + str(uuid.uuid4()) + room = { + "id": room_id, + "title": data.get("title", ""), + "subject": data.get("subject", ""), + "desc": data.get("desc", ""), + "tags": data.get("tags", []), + "creator": auth_user["username"] if auth_user else data.get("creator", ""), + "creatorId": auth_user["id"] if auth_user else str(data.get("creatorId", "")).strip(), + "createdAt": data.get("createdAt", 0), + "fulfilled": False, + "fulfilledAt": 0, + "fulfilledBy": "", + "courseId": "", + "courseTitle": "", + "participants": [] + } + self.rooms[room_id] = room + await self.state.storage.put("rooms", json.dumps(self.rooms)) + return Response(json.dumps(room), headers={"Content-Type": "application/json"}) + + if path.endswith('/api/waitingroom/join') and method == 'POST': + data = await request.json() + room_id = str(data.get("roomId") or data.get("room_id") or "").strip() + name = auth_user["username"] if auth_user else str(data.get("name") or data.get("user") or "").strip() + participant_id = auth_user["id"] if auth_user else str(data.get("participantId") or "").strip() + if not room_id: + return Response(json.dumps({"error": "roomId is required"}), status=400, headers={"Content-Type": "application/json"}) + room = self.rooms.get(room_id) + if not room: + return Response(json.dumps({"error": "Room not found"}), status=404, headers={"Content-Type": "application/json"}) + if not name: + name = "anonymous" + if not participant_id: + participant_id = name.lower() + + participants = _normalize_participants(room.get("participants")) + updated = False + for p in participants: + if p["id"] == participant_id: + p["name"] = name + updated = True + break + if not updated: + participants.append({"id": participant_id, "name": name}) + room["participants"] = participants + self.rooms[room_id] = room + await self.state.storage.put("rooms", json.dumps(self.rooms)) + return Response(json.dumps(room), headers={"Content-Type": "application/json"}) + + if path.endswith('/api/waitingroom/leave') and method == 'POST': + data = await request.json() + room_id = str(data.get("roomId") or data.get("room_id") or "").strip() + name = auth_user["username"] if auth_user else str(data.get("name") or data.get("user") or "").strip() + participant_id = auth_user["id"] if auth_user else str(data.get("participantId") or "").strip() + if not room_id or (not name and not participant_id): + return Response(json.dumps({"error": "roomId and participant identity are required"}), status=400, headers={"Content-Type": "application/json"}) + room = self.rooms.get(room_id) + if not room: + return Response(json.dumps({"error": "Room not found"}), status=404, headers={"Content-Type": "application/json"}) + + participants = _normalize_participants(room.get("participants")) + if participant_id: + room["participants"] = [p for p in participants if p["id"] != participant_id] + else: + room["participants"] = [p for p in participants if p["name"].lower() != name.lower()] + self.rooms[room_id] = room + await self.state.storage.put("rooms", json.dumps(self.rooms)) + return Response(json.dumps(room), headers={"Content-Type": "application/json"}) + + if path.endswith('/api/waitingroom/delete') and method == 'POST': + data = await request.json() + room_id = str(data.get("roomId") or data.get("room_id") or "").strip() + actor = auth_user["username"] if auth_user else str(data.get("actor") or data.get("name") or "").strip() + actor_id = auth_user["id"] if auth_user else str(data.get("actorId") or "").strip() + if not room_id: + return Response(json.dumps({"error": "roomId is required"}), status=400, headers={"Content-Type": "application/json"}) + room = self.rooms.get(room_id) + if not room: + return Response(json.dumps({"error": "Room not found"}), status=404, headers={"Content-Type": "application/json"}) + creator = str(room.get("creator") or "").strip() + creator_id = str(room.get("creatorId") or "").strip() + normalized_actor = actor.lower() + normalized_creator = creator.lower() + # Backward compatibility: + # some existing rooms may carry a stale/migrated creatorId while still having + # the correct creator name. Allow delete when either identity (id OR name) matches. + id_matches = bool(creator_id and actor_id and actor_id == creator_id) + name_matches = bool(actor and creator and normalized_actor == normalized_creator) + if creator_id and not (id_matches or name_matches): + return Response(json.dumps({"error": "Only the creator can delete this room"}), status=403, headers={"Content-Type": "application/json"}) + if not creator_id and actor and creator and not name_matches: + return Response(json.dumps({"error": "Only the creator can delete this room"}), status=403, headers={"Content-Type": "application/json"}) + + del self.rooms[room_id] + await self.state.storage.put("rooms", json.dumps(self.rooms)) + return Response(json.dumps({"success": True, "roomId": room_id}), headers={"Content-Type": "application/json"}) + + if path.endswith('/api/waitingroom/fulfill') and method == 'POST': + data = await request.json() + room_id = str(data.get("roomId") or data.get("room_id") or "").strip() + actor = auth_user["username"] if auth_user else str(data.get("actor") or data.get("name") or "").strip() + actor_id = auth_user["id"] if auth_user else str(data.get("actorId") or "").strip() + course_id = str(data.get("courseId") or "").strip() + course_title = str(data.get("courseTitle") or "").strip() + if not room_id: + return Response(json.dumps({"error": "roomId is required"}), status=400, headers={"Content-Type": "application/json"}) + if not course_id: + return Response(json.dumps({"error": "courseId is required"}), status=400, headers={"Content-Type": "application/json"}) + room = self.rooms.get(room_id) + if not room: + return Response(json.dumps({"error": "Room not found"}), status=404, headers={"Content-Type": "application/json"}) + creator = str(room.get("creator") or "").strip() + creator_id = str(room.get("creatorId") or "").strip() + normalized_actor = actor.lower() + normalized_creator = creator.lower() + id_matches = bool(creator_id and actor_id and actor_id == creator_id) + name_matches = bool(actor and creator and normalized_actor == normalized_creator) + if creator_id and not (id_matches or name_matches): + return Response(json.dumps({"error": "Only the creator can fulfill this room"}), status=403, headers={"Content-Type": "application/json"}) + if not creator_id and actor and creator and not name_matches: + return Response(json.dumps({"error": "Only the creator can fulfill this room"}), status=403, headers={"Content-Type": "application/json"}) + + room["fulfilled"] = True + room["fulfilledAt"] = int(data.get("fulfilledAt") or 0) or int(js.Date.now()) + room["fulfilledBy"] = actor + room["courseId"] = course_id + room["courseTitle"] = course_title + self.rooms[room_id] = room + await self.state.storage.put("rooms", json.dumps(self.rooms)) + return Response(json.dumps(room), headers={"Content-Type": "application/json"}) + + return Response(json.dumps({"error": "Not implemented"}), status=404, headers={"Content-Type": "application/json"}) from typing import Any, Dict from urllib.parse import urlparse, parse_qs -from workers import Response, DurableObject - import js from pyodide.ffi import to_js from js import WebSocketPair, WebSocketRequestResponsePair -import uuid _SENTRY_INITIALIZED = False _SENTRY_DSN: str = "" @@ -1192,6 +1381,83 @@ async def api_create_session(req, env): return ok({"id": sid}, "Session created") +async def api_delete_activity(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 + + act_id = (body.get("activity_id") or "").strip() + if not act_id: + return err("activity_id is required") + + 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) + + try: + await env.DB.prepare( + "DELETE FROM session_attendance WHERE session_id IN (SELECT id FROM sessions WHERE activity_id=?)" + ).bind(act_id).run() + await env.DB.prepare( + "DELETE FROM sessions WHERE activity_id=?" + ).bind(act_id).run() + await env.DB.prepare( + "DELETE FROM enrollments WHERE activity_id=?" + ).bind(act_id).run() + await env.DB.prepare( + "DELETE FROM activity_tags WHERE activity_id=?" + ).bind(act_id).run() + await env.DB.prepare( + "DELETE FROM activities WHERE id=? AND host_id=?" + ).bind(act_id, user["id"]).run() + except Exception as e: + await capture_exception(e, req, env, "api_delete_activity.delete") + return err("Failed to delete activity β€” please try again", 500) + + return ok(None, "Activity deleted") + + +async def api_delete_session(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 + + session_id = (body.get("session_id") or "").strip() + if not session_id: + return err("session_id is required") + + session = await env.DB.prepare( + "SELECT s.id,s.activity_id FROM sessions s" + " JOIN activities a ON a.id=s.activity_id" + " WHERE s.id=? AND a.host_id=?" + ).bind(session_id, user["id"]).first() + if not session: + return err("Session not found or access denied", 404) + + try: + await env.DB.prepare( + "DELETE FROM session_attendance WHERE session_id=?" + ).bind(session_id).run() + await env.DB.prepare( + "DELETE FROM sessions WHERE id=?" + ).bind(session_id).run() + except Exception as e: + await capture_exception(e, req, env, "api_delete_session.delete") + return err("Failed to delete session β€” please try again", 500) + + return ok(None, "Session deleted") + + async def api_list_tags(_req, env): res = await env.DB.prepare("SELECT id,name FROM tags ORDER BY name").all() tags = [{"id": r.id, "name": r.name} for r in (res.results or [])] @@ -1768,6 +2034,12 @@ async def _dispatch(request, env): if path == "/api/sessions" and method == "POST": return await api_create_session(request, env) + if path == "/api/activities/delete" and method == "POST": + return await api_delete_activity(request, env) + + if path == "/api/sessions/delete" and method == "POST": + return await api_delete_session(request, env) + if path == "/api/tags" and method == "GET": return await api_list_tags(request, env)