Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions public/course.html
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,130 @@ <h2 class="font-bold text-slate-800 text-lg mb-3">Welcome!</h2>
}
}


// 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 = '<p class="text-slate-400 text-sm">No comments yet. Be the first!</p>';
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
? '<button type="button" class="reply-btn text-xs text-indigo-500 hover:underline" data-id="' + c.id + '" data-author="' + escHtml(c.author) + '">Reply</button>'
: '';
const replyThread = replies
? '<div class="mt-3 pl-4 border-l-2 border-slate-100 space-y-3">' + replies + '</div>'
: '';
return '<div class="flex gap-3" id="comment-' + c.id + '">' +
'<div class="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-600 font-bold text-sm flex-shrink-0">' + initial + '</div>' +
'<div class="flex-1">' +
'<div class="flex items-center gap-2 mb-1">' +
'<span class="font-semibold text-slate-800 text-sm">' + escHtml(c.author) + '</span>' +
'<span class="text-xs text-slate-400">' + date + '</span>' +
'</div>' +
'<p class="text-slate-700 text-sm mb-1">' + escHtml(c.body) + '</p>' +
replyBtn + replyThread +
'</div></div>';
}
function startReply(commentId, author) {
replyToId = commentId;
const ind = document.getElementById('reply-indicator');
ind.classList.remove('hidden');
ind.innerHTML = 'Replying to <strong>' + escHtml(author) + '</strong> &mdash; <button type="button" onclick="cancelReply()" class="underline">cancel</button>';
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; });
}
</script>

<!-- Comments Section -->
<div class="mt-8 bg-white rounded-2xl shadow-sm border border-slate-100 p-6" id="comments-section">
<h2 class="font-bold text-slate-800 text-lg mb-4">&#128172; Discussion</h2>
<div id="comments-list" class="space-y-4 mb-6"></div>
<div id="comment-form" class="hidden">
<div id="reply-indicator" class="hidden text-xs text-indigo-600 mb-2">
Replying to a comment &mdash; <button type="button" onclick="cancelReply()" class="underline">cancel</button>
</div>
<textarea id="comment-input" rows="3" placeholder="Write a comment..."
class="w-full border border-slate-200 rounded-xl px-4 py-2 text-sm text-slate-700 focus:outline-none focus:ring-2 focus:ring-indigo-300 resize-none"></textarea>
<label for="comment-input" class="sr-only">Write a comment</label>
<button type="button" onclick="postComment()"
class="mt-2 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-5 py-2 rounded-xl text-sm transition">
Post Comment
</button>
</div>
<div id="comment-login-cta" class="hidden text-sm text-slate-500">
<a href="/login.html" class="text-indigo-600 font-semibold hover:underline">Login</a> to join the discussion.
</div>
</div>
</body>
</html>
18 changes: 18 additions & 0 deletions schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
124 changes: 124 additions & 0 deletions src/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)",
]


Expand Down Expand Up @@ -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)
Expand Down