|
14 | 14 | POST /api/sessions – add a session to activity [host] |
15 | 15 | GET /api/tags – list all tags |
16 | 16 | 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] |
17 | 23 |
|
18 | 24 | Security model |
19 | 25 | * ALL user PII (username, email, display name, role) is encrypted with a |
@@ -361,6 +367,22 @@ def _is_basic_auth_valid(req, env) -> bool: |
361 | 367 | "CREATE INDEX IF NOT EXISTS idx_sa_session ON session_attendance(session_id)", |
362 | 368 | "CREATE INDEX IF NOT EXISTS idx_sa_user ON session_attendance(user_id)", |
363 | 369 | "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)", |
364 | 386 | ] |
365 | 387 |
|
366 | 388 |
|
@@ -1203,6 +1225,107 @@ async def _dispatch(request, env): |
1203 | 1225 | return await serve_static(path, env) |
1204 | 1226 |
|
1205 | 1227 |
|
| 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 | + |
1206 | 1329 | async def on_fetch(request, env): |
1207 | 1330 | try: |
1208 | 1331 | return await _dispatch(request, env) |
|
0 commit comments