diff --git a/public/course.html b/public/course.html index fd3cbb6..6d7616b 100644 --- a/public/course.html +++ b/public/course.html @@ -223,11 +223,130 @@

Welcome!

} } + + // Comments + let replyToId = null; + function escHtml(s) { const d = document.createElement('div'); d.textContent = s||''; return d.innerHTML; } + async function loadComments() { + try { + const res = await fetch('/api/activities/' + actId + '/comments'); + const data = await res.json(); + if (res.ok) renderComments(data.data || []); + } catch(e) { console.error('loadComments', e); } + } + function renderComments(comments) { + const top = comments.filter(c => !c.parent_id); + const byParent = {}; + comments.filter(c => c.parent_id).forEach(c => { + byParent[c.parent_id] = byParent[c.parent_id] || []; + byParent[c.parent_id].push(c); + }); + const list = document.getElementById('comments-list'); + if (top.length === 0) { + list.innerHTML = '

No comments yet. Be the first!

'; + return; + } + list.innerHTML = top.map(c => renderComment(c, byParent)).join(''); + } + function renderComment(c, byParent) { + const replies = (byParent[c.id] || []).map(r => renderComment(r, byParent)).join(''); + const initial = (c.author || '?')[0].toUpperCase(); + const date = new Date(c.created_at).toLocaleDateString(); + const replyBtn = token + ? '' + : ''; + const replyThread = replies + ? '
' + replies + '
' + : ''; + return '
' + + '
' + initial + '
' + + '
' + + '
' + + '' + escHtml(c.author) + '' + + '' + date + '' + + '
' + + '

' + escHtml(c.body) + '

' + + replyBtn + replyThread + + '
'; + } + function startReply(commentId, author) { + replyToId = commentId; + const ind = document.getElementById('reply-indicator'); + ind.classList.remove('hidden'); + ind.innerHTML = 'Replying to ' + escHtml(author) + ''; + document.getElementById('comment-input').focus(); + } + function cancelReply() { + replyToId = null; + document.getElementById('reply-indicator').classList.add('hidden'); + } + async function postComment() { + const input = document.getElementById('comment-input'); + const body = input.value.trim(); + if (body.length === 0) return; + const btn = document.querySelector('#comment-form button[onclick="postComment()"]'); + btn.textContent = 'Posting...'; btn.disabled = true; + try { + const payload = { body }; + if (replyToId) payload.parent_id = replyToId; + const res = await fetch('/api/activities/' + actId + '/comments', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, + body: JSON.stringify(payload) + }); + const data = await res.json(); + if (res.ok) { + input.value = ''; + cancelReply(); + await loadComments(); + } else { + alert(data.error || 'Failed to post comment'); + } + } catch (e) { alert(e.message); } + finally { btn.textContent = 'Post Comment'; btn.disabled = false; } + } + document.addEventListener('click', function(e) { + if (e.target.classList.contains('reply-btn')) { + startReply(e.target.dataset.id, e.target.dataset.author); + } + }); + + document.addEventListener('DOMContentLoaded', function() { + if (token) { + const cf = document.getElementById('comment-form'); + if (cf) cf.classList.remove('hidden'); + } else { + const cl = document.getElementById('comment-login-cta'); + if (cl) cl.classList.remove('hidden'); + } + if (actId) loadComments(); + }); if (!actId) { document.getElementById('act-title').textContent = 'No activity selected'; } else { loadActivity().catch(e => { document.getElementById('act-title').textContent = 'Error: ' + e.message; }); } + + +
+

💬 Discussion

+
+ + +
\ No newline at end of file diff --git a/schema.sql b/schema.sql index 2aa67ff..50e881a 100644 --- a/schema.sql +++ b/schema.sql @@ -93,3 +93,21 @@ CREATE INDEX IF NOT EXISTS idx_sessions_activity ON sessions(activity_id); CREATE INDEX IF NOT EXISTS idx_sa_session ON session_attendance(session_id); CREATE INDEX IF NOT EXISTS idx_sa_user ON session_attendance(user_id); CREATE INDEX IF NOT EXISTS idx_at_activity ON activity_tags(activity_id); + +-- COMMENTS (discussion threads on activities) +CREATE TABLE IF NOT EXISTS comments ( + id TEXT PRIMARY KEY, + activity_id TEXT NOT NULL, + user_id TEXT NOT NULL, + body TEXT NOT NULL, -- encrypted + parent_id TEXT, -- NULL = top-level, else reply + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT, + FOREIGN KEY (activity_id) REFERENCES activities(id), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_comments_activity ON comments(activity_id); +CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id); +CREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id); diff --git a/src/worker.py b/src/worker.py index 9656277..a8aa24e 100644 --- a/src/worker.py +++ b/src/worker.py @@ -14,6 +14,9 @@ POST /api/sessions – add a session to activity [host] GET /api/tags – list all tags POST /api/activity-tags – add tags to an activity [host] + GET /api/activities/:id/comments – list comments for an activity + POST /api/activities/:id/comments – post a comment [auth] + DELETE /api/comments/:id – delete a comment [owner|host] Security model * ALL user PII (username, email, display name, role) is encrypted with a @@ -361,6 +364,22 @@ def _is_basic_auth_valid(req, env) -> bool: "CREATE INDEX IF NOT EXISTS idx_sa_session ON session_attendance(session_id)", "CREATE INDEX IF NOT EXISTS idx_sa_user ON session_attendance(user_id)", "CREATE INDEX IF NOT EXISTS idx_at_activity ON activity_tags(activity_id)", + # Comments + """CREATE TABLE IF NOT EXISTS comments ( + id TEXT PRIMARY KEY, + activity_id TEXT NOT NULL, + user_id TEXT NOT NULL, + body TEXT NOT NULL, + parent_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT, + FOREIGN KEY (activity_id) REFERENCES activities(id), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE + )""", + "CREATE INDEX IF NOT EXISTS idx_comments_activity ON comments(activity_id)", + "CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id)", + "CREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id)", ] @@ -1203,6 +1222,111 @@ async def _dispatch(request, env): return await serve_static(path, env) + +# --------------------------------------------------------------------------- +# Comments API +# --------------------------------------------------------------------------- + +async def api_get_comments(_req, env, activity_id: str, enc_key: str): + """GET /api/activities/:id/comments — list comments for an activity.""" + # Check activity exists + act = await env.DB.prepare( + "SELECT id FROM activities WHERE id = ?" + ).bind(activity_id).first() + if not act: + return err("Activity not found", 404) + + rows = await env.DB.prepare( + "SELECT c.id, c.body, c.parent_id, c.created_at, c.updated_at, " + "c.user_id, u.name AS author_name " + "FROM comments c " + "JOIN users u ON u.id = c.user_id " + "WHERE c.activity_id = ? " + "ORDER BY c.created_at ASC" + ).bind(activity_id).all() + + comments = [ + { + "id": r["id"], + "body": decrypt(r["body"], enc_key), + "parent_id": r["parent_id"], + "created_at": r["created_at"], + "updated_at": r["updated_at"], + "user_id": r["user_id"], + "author": decrypt(r["author_name"], enc_key), + } + for r in (rows.results or []) + ] + return ok(comments) + + +async def api_post_comment(req, env, activity_id: str, user, enc_key: str): + """POST /api/activities/:id/comments — post a comment (auth required).""" + if not user: + return err("Authentication required", 401) + + # Check activity exists + act = await env.DB.prepare( + "SELECT id FROM activities WHERE id = ?" + ).bind(activity_id).first() + if not act: + return err("Activity not found", 404) + + body, parse_err = await parse_json_object(req) + if parse_err: + return parse_err + + text = (body.get("body") or "").strip() + if not text: + return err("Comment body is required") + if len(text) > 2000: + return err("Comment must be 2000 characters or fewer") + + parent_id = body.get("parent_id") or None + if parent_id: + parent = await env.DB.prepare( + "SELECT id FROM comments WHERE id = ? AND activity_id = ?" + ).bind(parent_id, activity_id).first() + if not parent: + return err("Parent comment not found", 404) + + cid = new_id() + try: + await env.DB.prepare( + "INSERT INTO comments (id, activity_id, user_id, body, parent_id) " + "VALUES (?, ?, ?, ?, ?)" + ).bind(cid, activity_id, user["id"], encrypt(text, enc_key), parent_id).run() + except Exception as exc: + capture_exception(exc, where="api_post_comment") + return err("Failed to save comment", 500) + + return ok({"id": cid, "body": text, "parent_id": parent_id, + "user_id": user["id"], "activity_id": activity_id}, "Comment posted") + + +async def api_delete_comment(_req, env, comment_id: str, user): + """DELETE /api/comments/:id — delete own comment (or host deletes any).""" + if not user: + return err("Authentication required", 401) + + comment = await env.DB.prepare( + "SELECT c.id, c.user_id, a.host_id " + "FROM comments c JOIN activities a ON a.id = c.activity_id " + "WHERE c.id = ?" + ).bind(comment_id).first() + if not comment: + return err("Comment not found", 404) + + is_owner = comment["user_id"] == user["id"] + is_host = comment["host_id"] == user["id"] + + if not (is_owner or is_host): + return err("Permission denied", 403) + + await env.DB.prepare("DELETE FROM comments WHERE id = ?").bind(comment_id).run() + return ok(msg="Comment deleted") + + async def on_fetch(request, env): try: return await _dispatch(request, env)