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
99 changes: 99 additions & 0 deletions public/course.html
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,110 @@ <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>'
: '';
Comment thread
ayesha1145 marked this conversation as resolved.
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; }
}
Comment thread
ayesha1145 marked this conversation as resolved.
document.addEventListener('click', function(e) {
if (e.target.classList.contains('reply-btn')) {
startReply(e.target.dataset.id, e.target.dataset.author);
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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>
Comment thread
ayesha1145 marked this conversation as resolved.

</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);
144 changes: 144 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
Expand Down Expand Up @@ -450,6 +453,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)",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
]


Expand Down Expand Up @@ -1296,6 +1315,131 @@ 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, host_id FROM activities WHERE id = ?"
).bind(activity_id).first()
if not act:
return err("Activity not found", 404)
# Check user is enrolled or is host
if act["host_id"] != user["id"]:
enr = await env.DB.prepare(
"SELECT id FROM enrollments WHERE activity_id=? AND user_id=? AND status='active'"
).bind(activity_id, user["id"]).first()
if not enr:
return err("You must be enrolled to comment", 403)

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": await decrypt_aes(r["body"] or "", enc_key),
"parent_id": r["parent_id"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
"user_id": r["user_id"],
"author": await decrypt_aes(r["author_name"] or "", enc_key),
}
for r in (rows.results or [])
]
Comment thread
ayesha1145 marked this conversation as resolved.
return ok(comments)
Comment thread
ayesha1145 marked this conversation as resolved.


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, host_id FROM activities WHERE id = ?"
).bind(activity_id).first()
if not act:
return err("Activity not found", 404)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Check user is enrolled or is host
if act["host_id"] != user["id"]:
enr = await env.DB.prepare(
"SELECT id FROM enrollments WHERE activity_id=? AND user_id=? AND status='active'"
).bind(activity_id, user["id"]).first()
if not enr:
return err("You must be enrolled to comment", 403)

body, parse_err = await parse_json_object(req)
if parse_err:
return parse_err

raw_body = body.get("body")
if not isinstance(raw_body, str):
return err("Comment body must be a string")
text = raw_body.strip()
if not text:
return err("Comment body is required")
if len(text) > 2000:
return err("Comment must be 2000 characters or fewer")

raw_parent = body.get("parent_id")
if raw_parent is not None and not isinstance(raw_parent, str):
return err("parent_id must be a string")
parent_id = raw_parent 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)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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"], await encrypt_aes(text, enc_key), parent_id).run()
except Exception as exc:
capture_exception(exc, where="api_post_comment")
return err("Failed to save comment", 500)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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)
Comment thread
ayesha1145 marked this conversation as resolved.

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