Skip to content

Commit bd5e152

Browse files
committed
feat: add activity discussion/comments system with threaded replies
1 parent a08bafc commit bd5e152

3 files changed

Lines changed: 249 additions & 0 deletions

File tree

public/course.html

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,11 +223,119 @@ <h2 class="font-bold text-slate-800 text-lg mb-3">Welcome!</h2>
223223
}
224224
}
225225

226+
227+
// Comments
228+
let replyToId = null;
229+
function escHtml(s) { const d = document.createElement('div'); d.textContent = s||''; return d.innerHTML; }
230+
async function loadComments() {
231+
try {
232+
const res = await fetch('/api/activities/' + actId + '/comments');
233+
const data = await res.json();
234+
if (res.ok) renderComments(data.data || []);
235+
} catch(e) { console.error('loadComments', e); }
236+
}
237+
function renderComments(comments) {
238+
const top = comments.filter(c => !c.parent_id);
239+
const byParent = {};
240+
comments.filter(c => c.parent_id).forEach(c => {
241+
byParent[c.parent_id] = byParent[c.parent_id] || [];
242+
byParent[c.parent_id].push(c);
243+
});
244+
const list = document.getElementById('comments-list');
245+
if (top.length === 0) {
246+
list.innerHTML = '<p class="text-slate-400 text-sm">No comments yet. Be the first!</p>';
247+
return;
248+
}
249+
list.innerHTML = top.map(c => renderComment(c, byParent)).join('');
250+
}
251+
function renderComment(c, byParent) {
252+
const replies = (byParent[c.id] || []).map(r => renderComment(r, byParent)).join('');
253+
const initial = (c.author || '?')[0].toUpperCase();
254+
const date = new Date(c.created_at).toLocaleDateString();
255+
const replyBtn = token
256+
? '<button onclick="startReply('' + c.id + '', '' + escHtml(c.author) + '')" class="text-xs text-indigo-500 hover:underline">Reply</button>'
257+
: '';
258+
const replyThread = replies
259+
? '<div class="mt-3 pl-4 border-l-2 border-slate-100 space-y-3">' + replies + '</div>'
260+
: '';
261+
return '<div class="flex gap-3" id="comment-' + c.id + '">' +
262+
'<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>' +
263+
'<div class="flex-1">' +
264+
'<div class="flex items-center gap-2 mb-1">' +
265+
'<span class="font-semibold text-slate-800 text-sm">' + escHtml(c.author) + '</span>' +
266+
'<span class="text-xs text-slate-400">' + date + '</span>' +
267+
'</div>' +
268+
'<p class="text-slate-700 text-sm mb-1">' + escHtml(c.body) + '</p>' +
269+
replyBtn + replyThread +
270+
'</div></div>';
271+
}
272+
function startReply(commentId, author) {
273+
replyToId = commentId;
274+
const ind = document.getElementById('reply-indicator');
275+
ind.classList.remove('hidden');
276+
ind.innerHTML = 'Replying to <strong>' + escHtml(author) + '</strong> &mdash; <button onclick="cancelReply()" class="underline">cancel</button>';
277+
document.getElementById('comment-input').focus();
278+
}
279+
function cancelReply() {
280+
replyToId = null;
281+
document.getElementById('reply-indicator').classList.add('hidden');
282+
}
283+
async function postComment() {
284+
const input = document.getElementById('comment-input');
285+
const body = input.value.trim();
286+
if (body.length === 0) return;
287+
const btn = document.querySelector('#comment-form button[onclick="postComment()"]');
288+
btn.textContent = 'Posting...'; btn.disabled = true;
289+
try {
290+
const payload = { body };
291+
if (replyToId) payload.parent_id = replyToId;
292+
const res = await fetch('/api/activities/' + actId + '/comments', {
293+
method: 'POST',
294+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
295+
body: JSON.stringify(payload)
296+
});
297+
const data = await res.json();
298+
if (res.ok) {
299+
input.value = '';
300+
cancelReply();
301+
await loadComments();
302+
} else {
303+
alert(data.error || 'Failed to post comment');
304+
}
305+
} catch (e) { alert(e.message); }
306+
finally { btn.textContent = 'Post Comment'; btn.disabled = false; }
307+
}
308+
if (token) {
309+
document.getElementById('comment-form').classList.remove('hidden');
310+
} else {
311+
document.getElementById('comment-login-cta').classList.remove('hidden');
312+
}
313+
if (actId) loadComments();
226314
if (!actId) {
227315
document.getElementById('act-title').textContent = 'No activity selected';
228316
} else {
229317
loadActivity().catch(e => { document.getElementById('act-title').textContent = 'Error: ' + e.message; });
230318
}
231319
</script>
320+
321+
<!-- Comments Section -->
322+
<div class="mt-8 bg-white rounded-2xl shadow-sm border border-slate-100 p-6" id="comments-section">
323+
<h2 class="font-bold text-slate-800 text-lg mb-4">&#128172; Discussion</h2>
324+
<div id="comments-list" class="space-y-4 mb-6"></div>
325+
<div id="comment-form" class="hidden">
326+
<div id="reply-indicator" class="hidden text-xs text-indigo-600 mb-2">
327+
Replying to a comment &mdash; <button onclick="cancelReply()" class="underline">cancel</button>
328+
</div>
329+
<textarea id="comment-input" rows="3" placeholder="Write a comment..."
330+
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>
331+
<button onclick="postComment()"
332+
class="mt-2 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-5 py-2 rounded-xl text-sm transition">
333+
Post Comment
334+
</button>
335+
</div>
336+
<div id="comment-login-cta" class="hidden text-sm text-slate-500">
337+
<a href="/login.html" class="text-indigo-600 font-semibold hover:underline">Login</a> to join the discussion.
338+
</div>
339+
</div>
232340
</body>
233341
</html>

schema.sql

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,21 @@ CREATE INDEX IF NOT EXISTS idx_sessions_activity ON sessions(activity_id);
9393
CREATE INDEX IF NOT EXISTS idx_sa_session ON session_attendance(session_id);
9494
CREATE INDEX IF NOT EXISTS idx_sa_user ON session_attendance(user_id);
9595
CREATE INDEX IF NOT EXISTS idx_at_activity ON activity_tags(activity_id);
96+
97+
-- COMMENTS (discussion threads on activities)
98+
CREATE TABLE IF NOT EXISTS comments (
99+
id TEXT PRIMARY KEY,
100+
activity_id TEXT NOT NULL,
101+
user_id TEXT NOT NULL,
102+
body TEXT NOT NULL, -- encrypted
103+
parent_id TEXT, -- NULL = top-level, else reply
104+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
105+
updated_at TEXT,
106+
FOREIGN KEY (activity_id) REFERENCES activities(id),
107+
FOREIGN KEY (user_id) REFERENCES users(id),
108+
FOREIGN KEY (parent_id) REFERENCES comments(id)
109+
);
110+
111+
CREATE INDEX IF NOT EXISTS idx_comments_activity ON comments(activity_id);
112+
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);
113+
CREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id);

src/worker.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@
1414
POST /api/sessions – add a session to activity [host]
1515
GET /api/tags – list all tags
1616
POST /api/activity-tags – add tags to an activity [host]
17+
GET /api/activities/:id/comments – list comments for an activity
18+
POST /api/activities/:id/comments – post a comment [auth]
19+
DELETE /api/comments/:id – delete a comment [owner|host]
20+
GET /api/activities/:id/comments – list comments for an activity
21+
POST /api/activities/:id/comments – post a comment [auth]
22+
DELETE /api/comments/:id – delete a comment [owner|host]
1723
1824
Security model
1925
* ALL user PII (username, email, display name, role) is encrypted with a
@@ -361,6 +367,22 @@ def _is_basic_auth_valid(req, env) -> bool:
361367
"CREATE INDEX IF NOT EXISTS idx_sa_session ON session_attendance(session_id)",
362368
"CREATE INDEX IF NOT EXISTS idx_sa_user ON session_attendance(user_id)",
363369
"CREATE INDEX IF NOT EXISTS idx_at_activity ON activity_tags(activity_id)",
370+
# Comments
371+
"""CREATE TABLE IF NOT EXISTS comments (
372+
id TEXT PRIMARY KEY,
373+
activity_id TEXT NOT NULL,
374+
user_id TEXT NOT NULL,
375+
body TEXT NOT NULL,
376+
parent_id TEXT,
377+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
378+
updated_at TEXT,
379+
FOREIGN KEY (activity_id) REFERENCES activities(id),
380+
FOREIGN KEY (user_id) REFERENCES users(id),
381+
FOREIGN KEY (parent_id) REFERENCES comments(id)
382+
)""",
383+
"CREATE INDEX IF NOT EXISTS idx_comments_activity ON comments(activity_id)",
384+
"CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id)",
385+
"CREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id)",
364386
]
365387

366388

@@ -1203,6 +1225,107 @@ async def _dispatch(request, env):
12031225
return await serve_static(path, env)
12041226

12051227

1228+
1229+
# ---------------------------------------------------------------------------
1230+
# Comments API
1231+
# ---------------------------------------------------------------------------
1232+
1233+
async def api_get_comments(req, env, activity_id: str, enc_key: str):
1234+
"""GET /api/activities/:id/comments — list comments for an activity."""
1235+
# Check activity exists
1236+
act = await env.DB.prepare(
1237+
"SELECT id FROM activities WHERE id = ?"
1238+
).bind(activity_id).first()
1239+
if not act:
1240+
return err("Activity not found", 404)
1241+
1242+
rows = await env.DB.prepare(
1243+
"SELECT c.id, c.body, c.parent_id, c.created_at, c.updated_at, "
1244+
"c.user_id, u.name AS author_name "
1245+
"FROM comments c "
1246+
"JOIN users u ON u.id = c.user_id "
1247+
"WHERE c.activity_id = ? "
1248+
"ORDER BY c.created_at ASC"
1249+
).bind(activity_id).all()
1250+
1251+
comments = []
1252+
for r in (rows.results or []):
1253+
comments.append({
1254+
"id": r["id"],
1255+
"body": decrypt(r["body"], enc_key),
1256+
"parent_id": r["parent_id"],
1257+
"created_at": r["created_at"],
1258+
"updated_at": r["updated_at"],
1259+
"user_id": r["user_id"],
1260+
"author": decrypt(r["author_name"], enc_key),
1261+
})
1262+
return ok(comments)
1263+
1264+
1265+
async def api_post_comment(req, env, activity_id: str, user, enc_key: str):
1266+
"""POST /api/activities/:id/comments — post a comment (auth required)."""
1267+
if not user:
1268+
return err("Authentication required", 401)
1269+
1270+
# Check activity exists
1271+
act = await env.DB.prepare(
1272+
"SELECT id FROM activities WHERE id = ?"
1273+
).bind(activity_id).first()
1274+
if not act:
1275+
return err("Activity not found", 404)
1276+
1277+
body, parse_err = await parse_json_object(req)
1278+
if parse_err:
1279+
return parse_err
1280+
1281+
text = (body.get("body") or "").strip()
1282+
if not text:
1283+
return err("Comment body is required")
1284+
if len(text) > 2000:
1285+
return err("Comment must be 2000 characters or fewer")
1286+
1287+
parent_id = body.get("parent_id") or None
1288+
if parent_id:
1289+
parent = await env.DB.prepare(
1290+
"SELECT id FROM comments WHERE id = ? AND activity_id = ?"
1291+
).bind(parent_id, activity_id).first()
1292+
if not parent:
1293+
return err("Parent comment not found", 404)
1294+
1295+
cid = new_id()
1296+
await env.DB.prepare(
1297+
"INSERT INTO comments (id, activity_id, user_id, body, parent_id) "
1298+
"VALUES (?, ?, ?, ?, ?)"
1299+
).bind(cid, activity_id, user["id"], encrypt(text, enc_key), parent_id).run()
1300+
1301+
return ok({"id": cid, "body": text, "parent_id": parent_id,
1302+
"user_id": user["id"], "activity_id": activity_id}, "Comment posted")
1303+
1304+
1305+
async def api_delete_comment(req, env, comment_id: str, user):
1306+
"""DELETE /api/comments/:id — delete own comment (or host deletes any)."""
1307+
if not user:
1308+
return err("Authentication required", 401)
1309+
1310+
comment = await env.DB.prepare(
1311+
"SELECT c.id, c.user_id, a.host_id "
1312+
"FROM comments c JOIN activities a ON a.id = c.activity_id "
1313+
"WHERE c.id = ?"
1314+
).bind(comment_id).first()
1315+
if not comment:
1316+
return err("Comment not found", 404)
1317+
1318+
is_owner = comment["user_id"] == user["id"]
1319+
is_host = comment["host_id"] == user["id"]
1320+
is_admin = user.get("role") == "host"
1321+
1322+
if not (is_owner or is_host or is_admin):
1323+
return err("Permission denied", 403)
1324+
1325+
await env.DB.prepare("DELETE FROM comments WHERE id = ?").bind(comment_id).run()
1326+
return ok(msg="Comment deleted")
1327+
1328+
12061329
async def on_fetch(request, env):
12071330
try:
12081331
return await _dispatch(request, env)

0 commit comments

Comments
 (0)