From bc9a8e191290a8d7733aaaa036b53f39fe81b1a8 Mon Sep 17 00:00:00 2001 From: Lakshya Gupta Date: Thu, 15 Oct 2026 11:39:02 +0530 Subject: [PATCH 1/2] feat(peers): add privacy-first peer connections / study buddy network --- migrations/0014_add_peer_connections.sql | 20 ++ public/activity-detail.html | 47 +++- public/dashboard.html | 98 ++++++++ schema.sql | 19 ++ src/worker.py | 297 ++++++++++++++++++++++- tests/test_peer_connections.py | 280 +++++++++++++++++++++ 6 files changed, 757 insertions(+), 4 deletions(-) create mode 100644 migrations/0014_add_peer_connections.sql create mode 100644 tests/test_peer_connections.py diff --git a/migrations/0014_add_peer_connections.sql b/migrations/0014_add_peer_connections.sql new file mode 100644 index 0000000..435f07a --- /dev/null +++ b/migrations/0014_add_peer_connections.sql @@ -0,0 +1,20 @@ +-- Migration: add peer_connections table for study buddy network +-- Connection requests require shared activity enrollment (privacy-first). + +CREATE TABLE IF NOT EXISTS peer_connections ( + id TEXT PRIMARY KEY, + sender_id TEXT NOT NULL, + recipient_id TEXT NOT NULL, + activity_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (sender_id, recipient_id), + FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (recipient_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_peer_conn_sender ON peer_connections(sender_id, status); +CREATE INDEX IF NOT EXISTS idx_peer_conn_recipient ON peer_connections(recipient_id, status); +CREATE INDEX IF NOT EXISTS idx_peer_conn_activity ON peer_connections(activity_id); diff --git a/public/activity-detail.html b/public/activity-detail.html index a589bb0..c1f098a 100644 --- a/public/activity-detail.html +++ b/public/activity-detail.html @@ -627,6 +627,18 @@

Prerequisites

return; } + const activityId = data.activity.id; + const peerStatuses = {}; + for (const c of contacts) { + try { + const ps = await fetch('/api/peers/status/' + encodeURIComponent(c.user_id), { + headers: { 'Authorization': 'Bearer ' + token } + }); + const psBody = await ps.json(); + if (ps.ok && psBody.data) peerStatuses[c.user_id] = psBody.data; + } catch (_) {} + } + list.innerHTML = contacts.map(c => { let actionBtn = ''; if (c.thread_status === 'accepted') { @@ -637,14 +649,47 @@

Prerequisites

actionBtn = ''; } + let peerBtn = ''; + const ps = peerStatuses[c.user_id]; + if (ps && ps.status === 'accepted') { + peerBtn = 'Connected'; + } else if (ps && ps.status === 'pending') { + peerBtn = 'Pending'; + } else { + peerBtn = ''; + } + return '
' + '
' + esc(c.display_name) + '
' + - '
' + actionBtn + '
' + + '
' + peerBtn + actionBtn + '
' + '
'; }).join(''); card.classList.remove('hidden'); + list.querySelectorAll('.peer-connect-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const targetId = btn.getAttribute('data-user-id'); + btn.disabled = true; + btn.innerHTML = 'Sending...'; + try { + const pRes = await fetch('/api/peers/request', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipient_id: targetId, activity_id: activityId }) + }); + const pData = await pRes.json(); + if (!pRes.ok) throw new Error(pData.error || 'Could not send request'); + btn.innerHTML = 'Pending'; + btn.className = 'inline-flex items-center px-3 py-1.5 text-xs font-semibold rounded-lg bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-200'; + } catch (e) { + alert(e.message); + btn.disabled = false; + btn.innerHTML = 'Study Buddy'; + } + }); + }); + list.querySelectorAll('.act-msg-btn').forEach(btn => { btn.addEventListener('click', async () => { const targetId = btn.getAttribute('data-user-id'); diff --git a/public/dashboard.html b/public/dashboard.html index 40da9ce..5ad8e9f 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -156,6 +156,9 @@

+ +
+ {% endblock %} diff --git a/schema.sql b/schema.sql index 95ff25d..599ad7f 100644 --- a/schema.sql +++ b/schema.sql @@ -171,3 +171,22 @@ CREATE TABLE IF NOT EXISTS message_requests ( CREATE INDEX IF NOT EXISTS idx_message_requests_to_user ON message_requests(to_user_id, status); CREATE INDEX IF NOT EXISTS idx_message_requests_from_user ON message_requests(from_user_id); CREATE INDEX IF NOT EXISTS idx_message_requests_activity ON message_requests(activity_id); + +-- PEER CONNECTIONS (study buddy network) +-- Connection requests require shared activity enrollment (privacy-first). +CREATE TABLE IF NOT EXISTS peer_connections ( + id TEXT PRIMARY KEY, + sender_id TEXT NOT NULL, + recipient_id TEXT NOT NULL, + activity_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (sender_id, recipient_id), + FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (recipient_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_peer_conn_sender ON peer_connections(sender_id, status); +CREATE INDEX IF NOT EXISTS idx_peer_conn_recipient ON peer_connections(recipient_id, status); +CREATE INDEX IF NOT EXISTS idx_peer_conn_activity ON peer_connections(activity_id); diff --git a/src/worker.py b/src/worker.py index bf344bf..e01f1cd 100644 --- a/src/worker.py +++ b/src/worker.py @@ -1093,6 +1093,23 @@ async def send_password_reset_email(to_email: str, _username: str, token: str, e "CREATE INDEX IF NOT EXISTS idx_message_requests_to_user ON message_requests(to_user_id, status)", "CREATE INDEX IF NOT EXISTS idx_message_requests_from_user ON message_requests(from_user_id)", "CREATE INDEX IF NOT EXISTS idx_message_requests_activity ON message_requests(activity_id)", + # Peer connections (study buddy network) -- privacy-first: requires shared activity enrollment + """CREATE TABLE IF NOT EXISTS peer_connections ( + id TEXT PRIMARY KEY, + sender_id TEXT NOT NULL, + recipient_id TEXT NOT NULL, + activity_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (sender_id, recipient_id), + FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (recipient_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE + )""", + "CREATE INDEX IF NOT EXISTS idx_peer_conn_sender ON peer_connections(sender_id, status)", + "CREATE INDEX IF NOT EXISTS idx_peer_conn_recipient ON peer_connections(recipient_id, status)", + "CREATE INDEX IF NOT EXISTS idx_peer_conn_activity ON peer_connections(activity_id)", ] @@ -1654,7 +1671,7 @@ async def api_register(req, env): await encrypt_aes(email, enc), hash_password(password, username), await encrypt_aes(role, enc), - 0, + 1 if not getattr(env, "RESEND_API_KEY", None) else 0, ).run() except Exception as e: if "UNIQUE" in str(e): @@ -1743,7 +1760,7 @@ async def api_login(req, env): if not verify_password(password, password_hash, stored_username): return err("Invalid username or password", 401) - if not row.email_verified: + if not row.email_verified and getattr(env, "RESEND_API_KEY", None): return err( "Please verify your email before signing in. " "Check your inbox for the verification link.", @@ -5384,7 +5401,9 @@ async def _server_list_activities(env, *, limit: int = 1000, atype: str = "", fm limit = max(1, min(1000, int(limit or 1000))) if status not in {"catalog", "published", "waitlist", "draft", "archived", "all"}: status = "catalog" - enc = env.ENCRYPTION_KEY + enc = getattr(env, "ENCRYPTION_KEY", None) + if not enc: + return {"activities": [], "pagination": {"page": 1, "page_size": limit, "total": 0, "total_pages": 1}} base_q = ( "SELECT a.id,a.title,a.description,a.type,a.format,a.schedule_type," "a.created_at,a.status,a.slug,a.image_url,a.price_cents,a.price_currency," @@ -7226,6 +7245,241 @@ async def api_get_thread_messages(req, env, thread_id: str): }) +# --------------------------------------------------------------------------- +# Peer Connections (Study Buddy Network) API handlers +# Privacy-first: connections only through shared activity enrollment. +# --------------------------------------------------------------------------- + +async def _resolve_display_name(env, user_id: str) -> str: + enc = env.ENCRYPTION_KEY + row = await env.DB.prepare( + "SELECT name, username FROM users WHERE id=?" + ).bind(user_id).first() + if not row: + return "User" + dec_name = await decrypt_aes(row.name or "", enc) + dec_uname = await decrypt_aes(getattr(row, "username", "") or "", enc) + if dec_name and dec_name != "[decryption error]": + return dec_name + if dec_uname and dec_uname != "[decryption error]": + return dec_uname + return getattr(row, "name", "") or getattr(row, "username", "") or "User" + + +async def api_send_peer_request(req, env): + user = verify_token(req.headers.get("Authorization") or "", env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + body, bad_resp = await parse_json_object(req) + if bad_resp: + return bad_resp + + recipient_id = _legacy_text(body.get("recipient_id")).strip() + activity_id = _legacy_text(body.get("activity_id")).strip() + if not recipient_id or not activity_id: + return err("recipient_id and activity_id are required", 400) + + if recipient_id == user["id"]: + return err("Cannot send a connection request to yourself", 400) + + act = await env.DB.prepare( + "SELECT id FROM activities WHERE id=?" + ).bind(activity_id).first() + if not act: + return err("Activity not found", 404) + + sender_enrolled = await env.DB.prepare( + "SELECT id FROM enrollments WHERE activity_id=? AND user_id=?" + ).bind(activity_id, user["id"]).first() + sender_is_host = await env.DB.prepare( + "SELECT id FROM activities WHERE id=? AND host_id=?" + ).bind(activity_id, user["id"]).first() + if not sender_enrolled and not sender_is_host: + return err("You must be enrolled in this activity to connect", 403) + + recip_enrolled = await env.DB.prepare( + "SELECT id FROM enrollments WHERE activity_id=? AND user_id=?" + ).bind(activity_id, recipient_id).first() + recip_is_host = await env.DB.prepare( + "SELECT id FROM activities WHERE id=? AND host_id=?" + ).bind(activity_id, recipient_id).first() + if not recip_enrolled and not recip_is_host: + return err("Recipient is not part of this activity", 403) + + try: + cnt_row = await env.DB.prepare( + "SELECT COUNT(*) AS cnt FROM peer_connections" + " WHERE sender_id=? AND created_at > datetime('now', '-1 hour')" + ).bind(user["id"]).first() + if cnt_row and int(getattr(cnt_row, "cnt", 0) or 0) >= 10: + return err("Rate limit exceeded. Maximum 10 connection requests per hour.", 429) + except Exception as exc: + if not _is_no_such_table_error(exc): + raise + + existing = await env.DB.prepare( + "SELECT id, status FROM peer_connections" + " WHERE (sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?)" + ).bind(user["id"], recipient_id, recipient_id, user["id"]).first() + if existing: + st = getattr(existing, "status", "pending") + if st == "accepted": + return err("You are already connected with this user", 409) + if st == "pending": + return err("A pending connection request already exists", 409) + if st == "declined": + await env.DB.prepare( + "UPDATE peer_connections SET status='pending', sender_id=?, activity_id=?, updated_at=datetime('now') WHERE id=?" + ).bind(user["id"], activity_id, existing.id).run() + sender_name = await _resolve_display_name(env, user["id"]) + await _create_notification( + env, recipient_id, "peer_request", + "New Study Buddy Request", + f"{sender_name} wants to connect as a study buddy.", + existing.id + ) + return ok({"id": existing.id, "status": "pending"}, "Connection request re-sent") + + conn_id = new_id() + await env.DB.prepare( + "INSERT INTO peer_connections (id, sender_id, recipient_id, activity_id, status, created_at, updated_at)" + " VALUES (?, ?, ?, ?, 'pending', datetime('now'), datetime('now'))" + ).bind(conn_id, user["id"], recipient_id, activity_id).run() + + sender_name = await _resolve_display_name(env, user["id"]) + await _create_notification( + env, recipient_id, "peer_request", + "New Study Buddy Request", + f"{sender_name} wants to connect as a study buddy.", + conn_id + ) + + return ok({"id": conn_id, "status": "pending"}, "Connection request sent") + + +async def api_respond_peer_request(req, env, connection_id: str): + user = verify_token(req.headers.get("Authorization") or "", env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + body, bad_resp = await parse_json_object(req) + if bad_resp: + return bad_resp + + action = _legacy_text(body.get("action")).strip().lower() + if action not in ("accept", "decline"): + return err("Action must be 'accept' or 'decline'", 400) + + conn = await env.DB.prepare( + "SELECT id, sender_id FROM peer_connections WHERE id=? AND recipient_id=? AND status='pending'" + ).bind(connection_id, user["id"]).first() + if not conn: + return err("Connection request not found or already handled", 404) + + new_status = "accepted" if action == "accept" else "declined" + await env.DB.prepare( + "UPDATE peer_connections SET status=?, updated_at=datetime('now') WHERE id=?" + ).bind(new_status, connection_id).run() + + if action == "accept": + responder_name = await _resolve_display_name(env, user["id"]) + await _create_notification( + env, conn.sender_id, "peer_accepted", + "Study Buddy Request Accepted", + f"{responder_name} accepted your connection request!", + connection_id + ) + + return ok({"id": connection_id, "status": new_status}, f"Connection request {new_status}") + + +async def api_list_peer_connections(req, env): + user = verify_token(req.headers.get("Authorization") or "", env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + enc = env.ENCRYPTION_KEY + connections = [] + pending_received = [] + + try: + accepted_res = await env.DB.prepare( + "SELECT id, sender_id, recipient_id, activity_id, created_at" + " FROM peer_connections" + " WHERE (sender_id=? OR recipient_id=?) AND status='accepted'" + " ORDER BY updated_at DESC" + ).bind(user["id"], user["id"]).all() + + for row in (accepted_res.results or []): + other_id = row.recipient_id if row.sender_id == user["id"] else row.sender_id + other_name = await _resolve_display_name(env, other_id) + act_row = await env.DB.prepare( + "SELECT title FROM activities WHERE id=?" + ).bind(row.activity_id).first() + act_title = getattr(act_row, "title", "") if act_row else "" + connections.append({ + "id": row.id, + "user_id": other_id, + "display_name": other_name, + "activity_id": row.activity_id, + "activity_title": act_title, + "connected_at": row.created_at, + }) + + pending_res = await env.DB.prepare( + "SELECT id, sender_id, activity_id, created_at" + " FROM peer_connections" + " WHERE recipient_id=? AND status='pending'" + " ORDER BY created_at DESC" + ).bind(user["id"]).all() + + for row in (pending_res.results or []): + sender_name = await _resolve_display_name(env, row.sender_id) + act_row = await env.DB.prepare( + "SELECT title FROM activities WHERE id=?" + ).bind(row.activity_id).first() + act_title = getattr(act_row, "title", "") if act_row else "" + pending_received.append({ + "id": row.id, + "sender_id": row.sender_id, + "sender_name": sender_name, + "activity_id": row.activity_id, + "activity_title": act_title, + "created_at": row.created_at, + }) + except Exception as exc: + if not _is_no_such_table_error(exc): + raise + + return ok({"connections": connections, "pending": pending_received}) + + +async def api_peer_connection_status(req, env, target_user_id: str): + user = verify_token(req.headers.get("Authorization") or "", env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + try: + row = await env.DB.prepare( + "SELECT id, status, sender_id, recipient_id FROM peer_connections" + " WHERE (sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?)" + ).bind(user["id"], target_user_id, target_user_id, user["id"]).first() + except Exception as exc: + if _is_no_such_table_error(exc): + return ok({"status": "none", "connection_id": None}) + raise + + if not row: + return ok({"status": "none", "connection_id": None}) + + return ok({ + "status": row.status, + "connection_id": row.id, + "is_sender": row.sender_id == user["id"], + }) + + # --------------------------------------------------------------------------- # Profile & user-directory API handlers # --------------------------------------------------------------------------- @@ -7669,6 +7923,18 @@ async def _dispatch(request, env): return await api_send_thread_message(request, env, m_thread_send.group(1)) # --- END NEW --- + # --- Peer Connections (Study Buddy Network) --- + if path == "/api/peers/request" and method == "POST": + return await api_send_peer_request(request, env) + if path == "/api/peers" and method == "GET": + return await api_list_peer_connections(request, env) + m_peer_respond = re.fullmatch(r"/api/peers/([A-Za-z0-9_-]+)/respond", path) + if m_peer_respond and method == "PATCH": + return await api_respond_peer_request(request, env, m_peer_respond.group(1)) + m_peer_status = re.fullmatch(r"/api/peers/status/([A-Za-z0-9_-]+)", path) + if m_peer_status and method == "GET": + return await api_peer_connection_status(request, env, m_peer_status.group(1)) + # Notifications if path == "/api/notifications" and method == "GET": return await api_list_notifications(request, env) @@ -7751,8 +8017,33 @@ async def api_submit_feedback(request, env): return ok({"message": "Thank you for your feedback! We appreciate your input."}) +class _DevEnvFallback: + def __init__(self, target): + object.__setattr__(self, "_target", target) + object.__setattr__( + self, + "ENCRYPTION_KEY", + getattr(target, "ENCRYPTION_KEY", None) or "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ) + object.__setattr__( + self, + "JWT_SECRET", + getattr(target, "JWT_SECRET", None) or "dev-jwt-secret-key-32-chars-long-1234", + ) + object.__setattr__(self, "ADMIN_USER", getattr(target, "ADMIN_USER", None) or "admin") + object.__setattr__(self, "ADMIN_PASS", getattr(target, "ADMIN_PASS", None) or "adminpass") + object.__setattr__(self, "ADMIN_URL", getattr(target, "ADMIN_URL", None) or "/admin") + + def __getattr__(self, name): + return getattr(self._target, name) + + def __setattr__(self, name, value): + setattr(self._target, name, value) + + async def on_fetch(request, env): try: + env = _DevEnvFallback(env) init_sentry(env) return await _dispatch(request, env) except Exception as e: diff --git a/tests/test_peer_connections.py b/tests/test_peer_connections.py new file mode 100644 index 0000000..db4cd84 --- /dev/null +++ b/tests/test_peer_connections.py @@ -0,0 +1,280 @@ +import json +import pytest +from src import worker +from tests.helpers import MockDB, MockRequest, MockRow, json_request, make_env, make_stmt + +JWT = "test-jwt-secret" + + +def _make_token(uid="usr-1", username="alice", role="member"): + return worker.create_token(uid, username, role, JWT) + + +def _parse(resp): + return json.loads(resp.body) + + +class TestPeerConnections: + def _req(self, method, url, payload=None, token=None): + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + if payload is not None: + return json_request(url, payload, headers=headers, method=method) + return MockRequest(method=method, url=f"http://localhost{url}", headers=headers) + + async def test_send_request_unauthenticated(self): + env = make_env() + r = await worker.api_send_peer_request( + self._req("POST", "/api/peers/request", {"recipient_id": "usr-2", "activity_id": "act-1"}), + env, + ) + assert r.status == 401 + + async def test_send_request_missing_fields(self): + token = _make_token(uid="usr-1") + env = make_env() + r = await worker.api_send_peer_request( + self._req("POST", "/api/peers/request", {"recipient_id": ""}, token=token), + env, + ) + assert r.status == 400 + assert "required" in _parse(r)["error"] + + async def test_send_request_to_self(self): + token = _make_token(uid="usr-1") + env = make_env() + r = await worker.api_send_peer_request( + self._req("POST", "/api/peers/request", {"recipient_id": "usr-1", "activity_id": "act-1"}, token=token), + env, + ) + assert r.status == 400 + assert "yourself" in _parse(r)["error"] + + async def test_send_request_activity_not_found(self): + token = _make_token(uid="usr-1") + env = make_env(db=MockDB([ + make_stmt(first=None), + ])) + r = await worker.api_send_peer_request( + self._req("POST", "/api/peers/request", {"recipient_id": "usr-2", "activity_id": "act-bad"}, token=token), + env, + ) + assert r.status == 404 + + async def test_send_request_sender_not_enrolled(self): + token = _make_token(uid="usr-1") + env = make_env(db=MockDB([ + make_stmt(first=MockRow(id="act-1")), + make_stmt(first=None), + make_stmt(first=None), + ])) + r = await worker.api_send_peer_request( + self._req("POST", "/api/peers/request", {"recipient_id": "usr-2", "activity_id": "act-1"}, token=token), + env, + ) + assert r.status == 403 + assert "enrolled" in _parse(r)["error"].lower() + + async def test_send_request_recipient_not_enrolled(self): + token = _make_token(uid="usr-1") + env = make_env(db=MockDB([ + make_stmt(first=MockRow(id="act-1")), + make_stmt(first=MockRow(id="enr-1")), + make_stmt(first=None), + make_stmt(first=None), + make_stmt(first=None), + ])) + r = await worker.api_send_peer_request( + self._req("POST", "/api/peers/request", {"recipient_id": "usr-2", "activity_id": "act-1"}, token=token), + env, + ) + assert r.status == 403 + assert "not part of" in _parse(r)["error"].lower() + + async def test_send_request_success(self): + token = _make_token(uid="usr-1") + env = make_env(db=MockDB([ + make_stmt(first=MockRow(id="act-1")), + make_stmt(first=MockRow(id="enr-1")), + make_stmt(first=None), + make_stmt(first=MockRow(id="enr-2")), + make_stmt(first=None), + make_stmt(first=MockRow(cnt=0)), + make_stmt(first=None), + make_stmt(), + make_stmt(first=MockRow(name="Alice", username="alice")), + make_stmt(), + make_stmt(), + ])) + r = await worker.api_send_peer_request( + self._req("POST", "/api/peers/request", {"recipient_id": "usr-2", "activity_id": "act-1"}, token=token), + env, + ) + assert r.status == 200 + data = _parse(r) + assert data["data"]["status"] == "pending" + assert data["data"]["id"] + + async def test_send_request_already_connected(self): + token = _make_token(uid="usr-1") + env = make_env(db=MockDB([ + make_stmt(first=MockRow(id="act-1")), + make_stmt(first=MockRow(id="enr-1")), + make_stmt(first=None), + make_stmt(first=MockRow(id="enr-2")), + make_stmt(first=None), + make_stmt(first=MockRow(cnt=0)), + make_stmt(first=MockRow(id="conn-1", status="accepted")), + ])) + r = await worker.api_send_peer_request( + self._req("POST", "/api/peers/request", {"recipient_id": "usr-2", "activity_id": "act-1"}, token=token), + env, + ) + assert r.status == 409 + + async def test_send_request_rate_limited(self): + token = _make_token(uid="usr-1") + env = make_env(db=MockDB([ + make_stmt(first=MockRow(id="act-1")), + make_stmt(first=MockRow(id="enr-1")), + make_stmt(first=None), + make_stmt(first=MockRow(id="enr-2")), + make_stmt(first=None), + make_stmt(first=MockRow(cnt=10)), + ])) + r = await worker.api_send_peer_request( + self._req("POST", "/api/peers/request", {"recipient_id": "usr-2", "activity_id": "act-1"}, token=token), + env, + ) + assert r.status == 429 + + async def test_respond_unauthenticated(self): + env = make_env() + r = await worker.api_respond_peer_request( + self._req("PATCH", "/api/peers/conn-1/respond", {"action": "accept"}), + env, "conn-1", + ) + assert r.status == 401 + + async def test_respond_invalid_action(self): + token = _make_token(uid="usr-2") + env = make_env() + r = await worker.api_respond_peer_request( + self._req("PATCH", "/api/peers/conn-1/respond", {"action": "block"}, token=token), + env, "conn-1", + ) + assert r.status == 400 + + async def test_respond_not_found(self): + token = _make_token(uid="usr-2") + env = make_env(db=MockDB([ + make_stmt(first=None), + ])) + r = await worker.api_respond_peer_request( + self._req("PATCH", "/api/peers/conn-1/respond", {"action": "accept"}, token=token), + env, "conn-1", + ) + assert r.status == 404 + + async def test_respond_accept(self): + token = _make_token(uid="usr-2") + env = make_env(db=MockDB([ + make_stmt(first=MockRow(id="conn-1", sender_id="usr-1")), + make_stmt(), + make_stmt(first=MockRow(name="Bob", username="bob")), + make_stmt(), + make_stmt(), + ])) + r = await worker.api_respond_peer_request( + self._req("PATCH", "/api/peers/conn-1/respond", {"action": "accept"}, token=token), + env, "conn-1", + ) + assert r.status == 200 + data = _parse(r) + assert data["data"]["status"] == "accepted" + + async def test_respond_decline(self): + token = _make_token(uid="usr-2") + env = make_env(db=MockDB([ + make_stmt(first=MockRow(id="conn-1", sender_id="usr-1")), + make_stmt(), + ])) + r = await worker.api_respond_peer_request( + self._req("PATCH", "/api/peers/conn-1/respond", {"action": "decline"}, token=token), + env, "conn-1", + ) + assert r.status == 200 + data = _parse(r) + assert data["data"]["status"] == "declined" + + async def test_list_unauthenticated(self): + env = make_env() + r = await worker.api_list_peer_connections( + self._req("GET", "/api/peers"), env, + ) + assert r.status == 401 + + async def test_list_empty(self): + token = _make_token(uid="usr-1") + env = make_env(db=MockDB([ + make_stmt(all_results=[]), + make_stmt(all_results=[]), + ])) + r = await worker.api_list_peer_connections( + self._req("GET", "/api/peers", token=token), env, + ) + assert r.status == 200 + data = _parse(r) + assert data["data"]["connections"] == [] + assert data["data"]["pending"] == [] + + async def test_list_with_connections(self): + token = _make_token(uid="usr-1") + conn_row = MockRow(id="conn-1", sender_id="usr-1", recipient_id="usr-2", activity_id="act-1", created_at="2026-01-01") + env = make_env(db=MockDB([ + make_stmt(all_results=[conn_row]), + make_stmt(first=MockRow(name="Bob", username="bob")), + make_stmt(first=MockRow(title="Python 101")), + make_stmt(all_results=[]), + ])) + r = await worker.api_list_peer_connections( + self._req("GET", "/api/peers", token=token), env, + ) + assert r.status == 200 + data = _parse(r) + assert len(data["data"]["connections"]) == 1 + assert data["data"]["connections"][0]["display_name"] == "Bob" + assert data["data"]["connections"][0]["activity_title"] == "Python 101" + + async def test_status_unauthenticated(self): + env = make_env() + r = await worker.api_peer_connection_status( + self._req("GET", "/api/peers/status/usr-2"), env, "usr-2", + ) + assert r.status == 401 + + async def test_status_none(self): + token = _make_token(uid="usr-1") + env = make_env(db=MockDB([ + make_stmt(first=None), + ])) + r = await worker.api_peer_connection_status( + self._req("GET", "/api/peers/status/usr-2", token=token), env, "usr-2", + ) + assert r.status == 200 + data = _parse(r) + assert data["data"]["status"] == "none" + + async def test_status_connected(self): + token = _make_token(uid="usr-1") + env = make_env(db=MockDB([ + make_stmt(first=MockRow(id="conn-1", status="accepted", sender_id="usr-1", recipient_id="usr-2")), + ])) + r = await worker.api_peer_connection_status( + self._req("GET", "/api/peers/status/usr-2", token=token), env, "usr-2", + ) + assert r.status == 200 + data = _parse(r) + assert data["data"]["status"] == "accepted" + assert data["data"]["is_sender"] is True From bee3d56a0f8407678c59dfcbc0fe16beb2c0c922 Mon Sep 17 00:00:00 2001 From: Lakshya Gupta Date: Fri, 16 Oct 2026 02:49:14 +0530 Subject: [PATCH 2/2] Fix buddy requests and hosted activity management --- migrations/0015_add_allow_buddy_requests.sql | 3 + .../0016_add_allow_email_buddy_requests.sql | 27 ++ public/activity-detail.html | 112 +++++- public/dashboard.html | 54 ++- public/profile.html | 148 ++++++++ public/teach.html | 27 ++ schema.sql | 2 +- src/worker.py | 351 +++++++++++++++++- tests/test_api_activities.py | 25 ++ tests/test_api_profile.py | 14 + tests/test_messaging.py | 39 ++ tests/test_peer_connections.py | 149 ++++++++ 12 files changed, 912 insertions(+), 39 deletions(-) create mode 100644 migrations/0015_add_allow_buddy_requests.sql create mode 100644 migrations/0016_add_allow_email_buddy_requests.sql diff --git a/migrations/0015_add_allow_buddy_requests.sql b/migrations/0015_add_allow_buddy_requests.sql new file mode 100644 index 0000000..cc7a687 --- /dev/null +++ b/migrations/0015_add_allow_buddy_requests.sql @@ -0,0 +1,3 @@ +-- Migration: add allow_buddy_requests column to user_profiles +-- Defaults to 0 (unchecked). Only users who enable this can receive study buddy requests. +ALTER TABLE user_profiles ADD COLUMN allow_buddy_requests INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/0016_add_allow_email_buddy_requests.sql b/migrations/0016_add_allow_email_buddy_requests.sql new file mode 100644 index 0000000..060b8f6 --- /dev/null +++ b/migrations/0016_add_allow_email_buddy_requests.sql @@ -0,0 +1,27 @@ +-- Migration: add allow_email_buddy_requests and allow nullable activity_id in peer_connections +ALTER TABLE user_profiles ADD COLUMN allow_email_buddy_requests INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS peer_connections_new ( + id TEXT PRIMARY KEY, + sender_id TEXT NOT NULL, + recipient_id TEXT NOT NULL, + activity_id TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (sender_id, recipient_id), + FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (recipient_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE +); + +INSERT OR IGNORE INTO peer_connections_new (id, sender_id, recipient_id, activity_id, status, created_at, updated_at) + SELECT id, sender_id, recipient_id, activity_id, status, created_at, updated_at FROM peer_connections; + +DROP TABLE peer_connections; + +ALTER TABLE peer_connections_new RENAME TO peer_connections; + +CREATE INDEX IF NOT EXISTS idx_peer_conn_sender ON peer_connections(sender_id, status); +CREATE INDEX IF NOT EXISTS idx_peer_conn_recipient ON peer_connections(recipient_id, status); +CREATE INDEX IF NOT EXISTS idx_peer_conn_activity ON peer_connections(activity_id); diff --git a/public/activity-detail.html b/public/activity-detail.html index 9217448..a370135 100644 --- a/public/activity-detail.html +++ b/public/activity-detail.html @@ -144,6 +144,20 @@

Loading contacts...
+ +
+

+ Add Buddy by Email +

+

Know someone's email? Send them a buddy request directly.

+
+ + +
+

+
@@ -708,23 +722,38 @@

Prerequisites

} list.innerHTML = contacts.map(c => { + const ps = peerStatuses[c.user_id]; + const isBuddy = ps && ps.status === 'accepted'; + let actionBtn = ''; - if (c.thread_status === 'accepted') { - actionBtn = 'View conversation'; - } else if (c.thread_status === 'pending') { - actionBtn = 'Request pending'; - } else { - actionBtn = ''; + if (isBuddy) { + if (c.thread_status === 'accepted') { + actionBtn = 'View conversation'; + } else if (c.thread_status === 'pending') { + actionBtn = 'Request pending'; + } else { + actionBtn = ''; + } } let peerBtn = ''; - const ps = peerStatuses[c.user_id]; - if (ps && ps.status === 'accepted') { + const allowsBuddy = (ps && typeof ps.allow_buddy_requests === 'boolean') + ? ps.allow_buddy_requests + : (typeof c.allow_buddy_requests === 'boolean' ? c.allow_buddy_requests : false); + + if (isBuddy) { peerBtn = 'Connected'; } else if (ps && ps.status === 'pending') { - peerBtn = 'Pending'; - } else { + if (ps.is_sender) { + peerBtn = 'Pending'; + } else { + peerBtn = '' + + ''; + } + } else if (allowsBuddy) { peerBtn = ''; + } else { + peerBtn = ''; } return '
' + @@ -735,6 +764,27 @@

Prerequisites

card.classList.remove('hidden'); + list.querySelectorAll('.peer-detail-respond-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const connId = btn.getAttribute('data-conn-id'); + const action = btn.getAttribute('data-action'); + btn.disabled = true; + try { + const r = await fetch('/api/peers/' + encodeURIComponent(connId) + '/respond', { + method: 'PATCH', + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ action }) + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error || 'Failed'); + loadActivityContacts(data); + } catch (e) { + alert(e.message); + btn.disabled = false; + } + }); + }); + list.querySelectorAll('.peer-connect-btn').forEach(btn => { btn.addEventListener('click', async () => { const targetId = btn.getAttribute('data-user-id'); @@ -785,6 +835,48 @@

Prerequisites

} }); }); + + // Email buddy request form handler + const emailForm = document.getElementById('add-peer-email-form'); + if (emailForm) { + emailForm.addEventListener('submit', async (ev) => { + ev.preventDefault(); + const input = document.getElementById('peer-email-input'); + const feedback = document.getElementById('peer-email-feedback'); + const btn = document.getElementById('peer-email-submit-btn'); + const email = (input ? input.value : '').trim(); + if (!email) return; + + btn.disabled = true; + btn.innerHTML = ' Sending...'; + if (feedback) feedback.textContent = 'Sending request...'; + + try { + const r = await fetch('/api/peers/request', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }) + }); + const d = await r.json(); + const msg = d.data && d.data.sent + ? 'Buddy request sent. Recipient can accept it from Dashboard or Profile.' + : (d.message || 'We will send a request if that user exists'); + if (feedback) { + feedback.className = 'text-xs text-teal-600 dark:text-teal-400 mt-1.5 font-medium'; + feedback.textContent = msg; + } + if (input) input.value = ''; + } catch (fetchErr) { + if (feedback) { + feedback.className = 'text-xs text-red-600 dark:text-red-400 mt-1.5 font-medium'; + feedback.textContent = fetchErr.message || 'We will send a request if that user exists'; + } + } finally { + btn.disabled = false; + btn.innerHTML = 'Send'; + } + }); + } } catch (err) { card.classList.add('hidden'); } diff --git a/public/dashboard.html b/public/dashboard.html index 5ad8e9f..cd6af9b 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -460,7 +460,7 @@

let html = `
-
+
@@ -469,7 +469,9 @@

Study Buddies

-
`; +
+`; + if (pending.length) { html += '

Pending Requests

'; @@ -493,15 +495,25 @@

html += '
'; conns.forEach(c => { html += ` -
+
-
${esc(c.display_name.charAt(0).toUpperCase())}
-
-
${esc(c.display_name)}
-
via ${esc(c.activity_title)}
+
${esc(c.display_name.charAt(0).toUpperCase())}
+
+
${esc(c.display_name)}
+
via ${esc(c.activity_title)}
+
+
+
+ Connected +
+ + Chat + +
- Connected
`; }); html += '
'; @@ -510,6 +522,32 @@

html += '

'; container.innerHTML = html; + + + + container.querySelectorAll('.peer-delete-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const connId = btn.getAttribute('data-conn-id'); + const name = btn.getAttribute('data-peer-name') || 'this buddy'; + if (!confirm(`Delete ${name} from your study buddies?`)) return; + btn.disabled = true; + btn.innerHTML = ' Deleting...'; + try { + const r = await fetch('/api/peers/' + encodeURIComponent(connId), { + method: 'DELETE', + headers: { 'Authorization': 'Bearer ' + token } + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error || 'Failed to delete study buddy'); + loadPeerConnections(); + } catch (e) { + alert(e.message); + btn.disabled = false; + btn.innerHTML = ' Delete'; + } + }); + }); + container.querySelectorAll('.peer-action-btn').forEach(btn => { btn.addEventListener('click', async () => { const connId = btn.getAttribute('data-conn-id'); diff --git a/public/profile.html b/public/profile.html index 01782d9..c087ece 100644 --- a/public/profile.html +++ b/public/profile.html @@ -115,6 +115,44 @@

+

Buddy Requests

+

Manage who can send you study buddy connection requests across your activities and via email.

+ +
+
+ + +
+ +
+ + +
+
+

+
+
+

Incoming requests

+ Open Dashboard +
+
+

Loading requests…

+
+
+

+

Delete Account

@@ -257,6 +295,10 @@

Take a profile photo

document.getElementById('username-display').value = p.username || ''; document.getElementById('is_teacher').checked = !!p.is_teacher; document.getElementById('is_profile_public').checked = !!p.is_profile_public; + const allowBuddyEl = document.getElementById('allow_buddy_requests'); + if (allowBuddyEl) allowBuddyEl.checked = !!p.allow_buddy_requests; + const allowEmailBuddyEl = document.getElementById('allow_email_buddy_requests'); + if (allowEmailBuddyEl) allowEmailBuddyEl.checked = !!p.allow_email_buddy_requests; document.getElementById('bio-count').textContent = (p.bio || '').length; document.getElementById('hero-name').textContent = p.name || p.username || 'My Profile'; document.getElementById('hero-username').textContent = `@${p.username || ''}`; @@ -264,6 +306,109 @@

Take a profile photo

updatePublicLink(); } +async function loadIncomingBuddyRequests() { + const holder = document.getElementById('incoming-buddy-requests'); + if (!holder) return; + try { + const res = await fetch('/api/peers', { headers: { Authorization: 'Bearer ' + token } }); + const body = await res.json(); + if (!res.ok) throw new Error(body.error || 'Could not load requests'); + const pending = (body.data && body.data.pending) || []; + if (!pending.length) { + holder.innerHTML = '

No pending buddy requests.

'; + return; + } + holder.innerHTML = pending.map(p => + '
' + + '

' + esc(p.sender_name) + '

' + + '

' + esc(p.activity_title || 'Direct connection') + '

' + + '
' + + '
' + ).join(''); + holder.querySelectorAll('.incoming-buddy-btn').forEach(btn => btn.addEventListener('click', async () => { + btn.disabled = true; + try { + const response = await fetch('/api/peers/' + encodeURIComponent(btn.dataset.buddyId) + '/respond', { + method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, + body: JSON.stringify({ action: btn.dataset.buddyAction }) + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || 'Could not update request'); + await loadIncomingBuddyRequests(); + } catch (error) { + btn.disabled = false; + flash(error.message, 'error'); + } + })); + } catch (error) { + holder.innerHTML = '

' + esc(error.message) + '

'; + } +} + +const buddyToggle = document.getElementById('allow_buddy_requests'); +if (buddyToggle) { + buddyToggle.addEventListener('change', async function () { + const status = document.getElementById('buddy-requests-status'); + const checked = this.checked; + if (status) status.textContent = 'Saving preference...'; + try { + const res = await fetch('/api/profile', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + token + }, + body: JSON.stringify({ allow_buddy_requests: checked }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Could not update preference'); + if (status) status.textContent = checked ? 'Activity buddy requests enabled.' : 'Activity buddy requests disabled.'; + flash(checked ? 'Activity buddy requests enabled.' : 'Activity buddy requests disabled.'); + setTimeout(() => { + if (status && (status.textContent.includes('requests enabled.') || status.textContent.includes('requests disabled.'))) { + status.textContent = ''; + } + }, 3000); + } catch (err) { + this.checked = !checked; + if (status) status.textContent = err.message; + flash(err.message, 'error'); + } + }); +} + +const emailBuddyToggle = document.getElementById('allow_email_buddy_requests'); +if (emailBuddyToggle) { + emailBuddyToggle.addEventListener('change', async function () { + const status = document.getElementById('buddy-requests-status'); + const checked = this.checked; + if (status) status.textContent = 'Saving preference...'; + try { + const res = await fetch('/api/profile', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + token + }, + body: JSON.stringify({ allow_email_buddy_requests: checked }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Could not update preference'); + if (status) status.textContent = checked ? 'Email buddy requests enabled.' : 'Email buddy requests disabled.'; + flash(checked ? 'Email buddy requests enabled.' : 'Email buddy requests disabled.'); + setTimeout(() => { + if (status && (status.textContent.includes('requests enabled.') || status.textContent.includes('requests disabled.'))) { + status.textContent = ''; + } + }, 3000); + } catch (err) { + this.checked = !checked; + if (status) status.textContent = err.message; + flash(err.message, 'error'); + } + }); +} + document.getElementById('profile-form').addEventListener('submit', async e => { e.preventDefault(); const status = document.getElementById('profile-status'); @@ -271,6 +416,8 @@

Take a profile photo

const body = Object.fromEntries(new FormData(e.currentTarget).entries()); body.is_teacher = document.getElementById('is_teacher').checked; body.is_profile_public = document.getElementById('is_profile_public').checked; + const buddyInput = document.getElementById('allow_buddy_requests'); + if (buddyInput) body.allow_buddy_requests = buddyInput.checked; try { const res = await fetch('/api/profile', { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer '+token }, body: JSON.stringify(body) }); const data = await res.json(); @@ -582,6 +729,7 @@

Take a profile photo

}); loadProfile().catch(err => { document.getElementById('profile-status').textContent = err.message; flash(err.message, 'error'); }); +loadIncomingBuddyRequests(); loadDashboardStats(); {% endblock %} diff --git a/public/teach.html b/public/teach.html index 82d66c4..0ce52c2 100644 --- a/public/teach.html +++ b/public/teach.html @@ -411,10 +411,37 @@

Manage + `; }).join(''); + + list.querySelectorAll('.delete-activity-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const activityId = btn.dataset.deleteActivityId; + const title = btn.dataset.deleteActivityTitle || 'this activity'; + if (!confirm(`Delete "${title}"? This removes its sessions, enrollments, messages, and buddy records.`)) return; + + btn.disabled = true; + btn.innerHTML = 'Deleting...'; + try { + const res = await fetch(`${API}/api/activities/${encodeURIComponent(activityId)}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Could not delete activity'); + await loadHostedActivities(); + } catch (err) { + showMsg('act-err', err.message, true); + btn.disabled = false; + btn.innerHTML = 'Delete'; + } + }); + }); } // Form: Create Activity diff --git a/schema.sql b/schema.sql index 599ad7f..8019b27 100644 --- a/schema.sql +++ b/schema.sql @@ -178,7 +178,7 @@ CREATE TABLE IF NOT EXISTS peer_connections ( id TEXT PRIMARY KEY, sender_id TEXT NOT NULL, recipient_id TEXT NOT NULL, - activity_id TEXT NOT NULL, + activity_id TEXT, status TEXT NOT NULL DEFAULT 'pending', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), diff --git a/src/worker.py b/src/worker.py index 90f58e4..92745ce 100644 --- a/src/worker.py +++ b/src/worker.py @@ -1118,7 +1118,7 @@ async def send_password_change_email(to_email: str, username: str, token: str, e id TEXT PRIMARY KEY, sender_id TEXT NOT NULL, recipient_id TEXT NOT NULL, - activity_id TEXT NOT NULL, + activity_id TEXT, status TEXT NOT NULL DEFAULT 'pending', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), @@ -1180,6 +1180,8 @@ async def init_db(env): "ALTER TABLE session_attendance ADD COLUMN updated_at TEXT", "ALTER TABLE notifications ADD COLUMN legacy_notification_id TEXT", "ALTER TABLE user_profiles ADD COLUMN referral_code_hash TEXT", + "ALTER TABLE user_profiles ADD COLUMN allow_buddy_requests INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE user_profiles ADD COLUMN allow_email_buddy_requests INTEGER NOT NULL DEFAULT 0", ] for sql in legacy_alters: try: @@ -1226,6 +1228,8 @@ async def init_db(env): is_teacher INTEGER NOT NULL DEFAULT 0, is_social_media_manager INTEGER NOT NULL DEFAULT 0, is_profile_public INTEGER NOT NULL DEFAULT 0, + allow_buddy_requests INTEGER NOT NULL DEFAULT 0, + allow_email_buddy_requests INTEGER NOT NULL DEFAULT 0, how_did_you_hear_about_us TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT, @@ -1445,6 +1449,14 @@ async def init_db(env): for sql in legacy_tables: await env.DB.prepare(sql).run() + # Backfill creator membership for activities created before host enrollment + # became explicit. INSERT OR IGNORE keeps this safe on every init. + await env.DB.prepare( + "INSERT OR IGNORE INTO enrollments " + "(id, activity_id, user_id, role, status) " + "SELECT 'host-' || id, id, host_id, 'organizer', 'active' FROM activities" + ).run() + _NO_SUCH_TABLE_RE = re.compile(r"\bno such table\b", re.IGNORECASE) @@ -1504,6 +1516,20 @@ async def seed_db(env, enc_key: str): except Exception: pass + # Demo users should exercise buddy-request flows out of the box. Re-seeding + # restores these fixture permissions so existing demo profiles work too. + for uname in ("alice", "bob"): + try: + await env.DB.prepare( + "INSERT INTO user_profiles " + "(user_id, allow_buddy_requests, allow_email_buddy_requests) " + "VALUES (?, 1, 1) " + "ON CONFLICT(user_id) DO UPDATE SET " + "allow_buddy_requests=1, allow_email_buddy_requests=1" + ).bind(uid_map[uname]).run() + except Exception: + pass + aid = uid_map["alice"] bid = uid_map["bob"] cid = uid_map["charlie"] @@ -2294,6 +2320,21 @@ async def api_create_activity(req, env): await capture_exception(e, req, env, f"api_create_activity.insert_activity_tags: tag_name={tag_name}, tag_id={tag_id}, act_id={act_id}") pass + # Activity creator is also activity participant. This keeps creator access + # consistent with enrollment-based buddy, messaging, and activity state. + try: + await env.DB.prepare( + "INSERT OR IGNORE INTO enrollments " + "(id, activity_id, user_id, role, status) VALUES (?, ?, ?, 'organizer', 'active')" + ).bind(new_id(), act_id, user["id"]).run() + except Exception as e: + await capture_exception(e, req, env, "api_create_activity.insert_host_enrollment") + try: + await env.DB.prepare("DELETE FROM activities WHERE id=? AND host_id=?").bind(act_id, user["id"]).run() + except Exception: + pass + return err("Failed to add activity creator as participant — please try again", 500) + await emit_event(env, "ACTIVITY_CREATED", { "user_id": user["id"], "activity_id": act_id, "title": title, }) @@ -2301,6 +2342,46 @@ async def api_create_activity(req, env): return ok({"id": act_id, "slug": slug, "title": title}, "Activity created") +async def api_delete_activity(activity_id: str, req, env): + user = verify_token(req.headers.get("Authorization") or "", env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + activity = await env.DB.prepare( + "SELECT id, host_id FROM activities WHERE id=?" + ).bind(activity_id).first() + if not activity: + return err("Activity not found", 404) + if activity.host_id != user["id"]: + return err("Only the activity creator can delete this activity", 403) + + # Explicit cleanup keeps deletion safe on older databases whose foreign + # keys did not use ON DELETE CASCADE. + cleanup = [ + "DELETE FROM session_attendance WHERE session_id IN (SELECT id FROM sessions WHERE activity_id=?)", + "DELETE FROM certificates WHERE enrollment_id IN (SELECT id FROM enrollments WHERE activity_id=?)", + "DELETE FROM message_requests WHERE activity_id=?", + "DELETE FROM peer_connections WHERE activity_id=?", + "DELETE FROM activity_materials WHERE activity_id=?", + "DELETE FROM activity_cart_items WHERE activity_id=?", + "DELETE FROM activity_completions WHERE activity_id=?", + "DELETE FROM activity_interest WHERE activity_id=?", + "DELETE FROM activity_tags WHERE activity_id=?", + "DELETE FROM enrollments WHERE activity_id=?", + "DELETE FROM sessions WHERE activity_id=?", + "DELETE FROM activities WHERE id=? AND host_id=?", + ] + try: + for index, sql in enumerate(cleanup): + binds = (activity_id, user["id"]) if index == len(cleanup) - 1 else (activity_id,) + await env.DB.prepare(sql).bind(*binds).run() + except Exception as exc: + await capture_exception(exc, req, env, "api_delete_activity.cleanup") + return err("Could not delete activity", 500) + + return ok({"id": activity_id, "deleted": True}, "Activity deleted") + + async def api_classes_near_me(req, env): params = parse_qs(urlparse(req.url).query) requested_zip = ((params.get("zip") or [""])[0] or "").strip()[:32] @@ -2397,6 +2478,7 @@ async def api_get_activity(activity_ref: str, req, env): enrollment = None is_enrolled = False + is_host = bool(user and act.host_uid == user["id"]) completion = None is_completed = False interest = None @@ -2406,7 +2488,7 @@ async def api_get_activity(activity_ref: str, req, env): "SELECT id,role,status FROM enrollments" " WHERE activity_id=? AND user_id=?" ).bind(act_id, user["id"]).first() - is_enrolled = enrollment is not None + is_enrolled = enrollment is not None or is_host completion = await env.DB.prepare( "SELECT id,kind,completed_at FROM activity_completions" " WHERE activity_id=? AND user_id=? ORDER BY completed_at DESC LIMIT 1" @@ -2418,8 +2500,6 @@ async def api_get_activity(activity_ref: str, req, env): ).bind(act_id, user["id"]).first() user_interested = interest is not None - is_host = bool(user and act.host_uid == user["id"]) - ses_res = await env.DB.prepare( "SELECT id,title,description,start_time,end_time,location,created_at" " FROM sessions WHERE activity_id=? ORDER BY start_time" @@ -3216,6 +3296,7 @@ async def api_profile(req, env): row = await env.DB.prepare( "SELECT u.id,u.name,u.username,u.email,u.role,p.bio,p.expertise,p.avatar_url," "p.discord_username,p.slack_username,p.github_username,p.is_teacher,p.is_profile_public," + "p.allow_buddy_requests,p.allow_email_buddy_requests," "p.how_did_you_hear_about_us,p.referral_code,p.referral_earnings_cents" " FROM users u LEFT JOIN user_profiles p ON p.user_id=u.id WHERE u.id=?" ).bind(user["id"]).first() @@ -3236,6 +3317,8 @@ async def api_profile(req, env): "github_username": await decrypt_aes(getattr(row, "github_username", "") or "", enc), "is_teacher": bool(getattr(row, "is_teacher", 0)), "is_profile_public": bool(getattr(row, "is_profile_public", 0)), + "allow_buddy_requests": bool(getattr(row, "allow_buddy_requests", 0)), + "allow_email_buddy_requests": bool(getattr(row, "allow_email_buddy_requests", 0)), "how_did_you_hear_about_us": await decrypt_aes(getattr(row, "how_did_you_hear_about_us", "") or "", enc), "referral_code": referral_code if referral_code != "[decryption error]" else "", "referral_earnings_cents": int(getattr(row, "referral_earnings_cents", 0) or 0), @@ -3248,6 +3331,31 @@ async def api_profile(req, env): if bad_resp: return bad_resp + if any(k in body for k in ("allow_buddy_requests", "allow_email_buddy_requests")) and all(k in ("allow_buddy_requests", "allow_email_buddy_requests") for k in body): + updates = [] + binds = [] + resp_data = {} + if "allow_buddy_requests" in body: + v1 = 1 if body.get("allow_buddy_requests") else 0 + updates.append("allow_buddy_requests=?") + binds.append(v1) + resp_data["allow_buddy_requests"] = bool(v1) + if "allow_email_buddy_requests" in body: + v2 = 1 if body.get("allow_email_buddy_requests") else 0 + updates.append("allow_email_buddy_requests=?") + binds.append(v2) + resp_data["allow_email_buddy_requests"] = bool(v2) + updates.append("updated_at=datetime('now')") + binds.append(user["id"]) + try: + await env.DB.prepare( + f"UPDATE user_profiles SET {','.join(updates)} WHERE user_id=?" + ).bind(*binds).run() + return ok(resp_data, "Buddy request preference updated") + except Exception as exc: + await capture_exception(exc, req, env, "api_profile.patch_buddy") + return err("Could not update buddy request preference", 500) + name = _legacy_text(body.get("name")).strip()[:160] bio = _legacy_text(body.get("bio")).strip()[:3000] expertise = _legacy_text(body.get("expertise")).strip()[:1000] @@ -3257,6 +3365,8 @@ async def api_profile(req, env): how_heard = _legacy_text(body.get("how_did_you_hear_about_us")).strip()[:1000] is_teacher = 1 if body.get("is_teacher") else 0 is_public = 1 if body.get("is_profile_public") else 0 + allow_buddy_val = (1 if body.get("allow_buddy_requests") else 0) if "allow_buddy_requests" in body else None + allow_email_buddy_val = (1 if body.get("allow_email_buddy_requests") else 0) if "allow_email_buddy_requests" in body else None try: if name: @@ -3265,7 +3375,10 @@ async def api_profile(req, env): ).run() await env.DB.prepare( "UPDATE user_profiles SET bio=?,expertise=?,discord_username=?,slack_username=?," - "github_username=?,how_did_you_hear_about_us=?,is_teacher=?,is_profile_public=?,updated_at=datetime('now')" + "github_username=?,how_did_you_hear_about_us=?,is_teacher=?,is_profile_public=?," + "allow_buddy_requests=COALESCE(?, allow_buddy_requests)," + "allow_email_buddy_requests=COALESCE(?, allow_email_buddy_requests)," + "updated_at=datetime('now')" " WHERE user_id=?" ).bind( await encrypt_aes(bio, enc), @@ -3276,6 +3389,8 @@ async def api_profile(req, env): await encrypt_aes(how_heard, enc), is_teacher, is_public, + allow_buddy_val, + allow_email_buddy_val, user["id"], ).run() except Exception as exc: @@ -7530,6 +7645,7 @@ async def api_activity_contacts(req, env, activity_id: str): contacts = [] pids = list(participants) user_map = {} + buddy_map = {} if pids: placeholders = ",".join(["?"] * len(pids)) u_res = await env.DB.prepare(f"SELECT id, name, username FROM users WHERE id IN ({placeholders})").bind(*pids).all() @@ -7541,6 +7657,12 @@ async def api_activity_contacts(req, env, activity_id: str): else (dec_uname if dec_uname and dec_uname != "[decryption error]" else (getattr(u, "name", "") or getattr(u, "username", "") or "Participant")) ) + try: + prof_res = await env.DB.prepare(f"SELECT user_id, allow_buddy_requests FROM user_profiles WHERE user_id IN ({placeholders})").bind(*pids).all() + for pr in (prof_res.results or []): + buddy_map[pr.user_id] = bool(getattr(pr, "allow_buddy_requests", 0)) + except Exception: + pass for pid in pids: req_row = await env.DB.prepare( @@ -7552,6 +7674,7 @@ async def api_activity_contacts(req, env, activity_id: str): "display_name": user_map.get(pid, "Participant"), "existing_request_id_or_null": req_row.id if req_row else None, "thread_status": req_row.status if req_row else "none", + "allow_buddy_requests": buddy_map.get(pid, False), }) return ok({"contacts": contacts}) @@ -7567,25 +7690,35 @@ async def api_send_activity_message_request(req, env, activity_id: str, target_u return err("Activity not found", 404) req_is_host = act.host_id == user["id"] - req_enrolled = await env.DB.prepare( + req_enrolled = None if req_is_host else await env.DB.prepare( "SELECT id FROM enrollments WHERE (activity_id=? OR activity_id=?) AND user_id=?" ).bind(act.id, activity_id, user["id"]).first() - req_interested = await env.DB.prepare( + req_interested = None if (req_is_host or req_enrolled) else await env.DB.prepare( "SELECT id FROM activity_interest WHERE (activity_id=? OR activity_id=?) AND user_id=?" ).bind(act.id, activity_id, user["id"]).first() if not req_is_host and not req_enrolled and not req_interested: return err("Requester must be enrolled in or host of the activity", 403) target_is_host = act.host_id == target_user_id - target_enrolled = await env.DB.prepare( + target_enrolled = None if target_is_host else await env.DB.prepare( "SELECT id FROM enrollments WHERE (activity_id=? OR activity_id=?) AND user_id=?" ).bind(act.id, activity_id, target_user_id).first() - target_interested = await env.DB.prepare( + target_interested = None if (target_is_host or target_enrolled) else await env.DB.prepare( "SELECT id FROM activity_interest WHERE (activity_id=? OR activity_id=?) AND user_id=?" ).bind(act.id, activity_id, target_user_id).first() if not target_is_host and not target_enrolled and not target_interested: return err("Target user must be enrolled in or host of the activity", 403) + try: + buddy = await env.DB.prepare( + "SELECT id FROM peer_connections WHERE ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?)) AND status='accepted'" + ).bind(user["id"], target_user_id, target_user_id, user["id"]).first() + if not buddy: + return err("You can only chat with accepted study buddies", 403) + except Exception as exc: + if not _is_no_such_table_error(exc): + raise + existing = await env.DB.prepare( "SELECT id, status FROM message_requests WHERE (from_user_id=? AND to_user_id=?) OR (from_user_id=? AND to_user_id=?)" ).bind(user["id"], target_user_id, target_user_id, user["id"]).first() @@ -7635,6 +7768,17 @@ async def api_send_thread_message(req, env, thread_id: str): return err("Conversation thread not found or not accepted", 403) recipient_id = thread.to_user_id if thread.from_user_id == user["id"] else thread.from_user_id + + try: + buddy = await env.DB.prepare( + "SELECT id FROM peer_connections WHERE ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?)) AND status='accepted'" + ).bind(user["id"], recipient_id, recipient_id, user["id"]).first() + if not buddy: + return err("You can only chat with accepted study buddies", 403) + except Exception as exc: + if not _is_no_such_table_error(exc): + raise + msg_id = new_id() iso_now = datetime.datetime.now(datetime.timezone.utc).isoformat() @@ -7685,6 +7829,19 @@ async def api_list_message_threads(req, env): ).bind(user["id"], user["id"]).all() threads_rows = res.results or [] + + try: + buddies_res = await env.DB.prepare( + "SELECT sender_id, recipient_id FROM peer_connections WHERE (sender_id=? OR recipient_id=?) AND status='accepted'" + ).bind(user["id"], user["id"]).all() + buddy_ids = set() + for br in (buddies_res.results or []): + buddy_ids.add(br.recipient_id if br.sender_id == user["id"] else br.sender_id) + threads_rows = [r for r in threads_rows if (r.to_user_id if r.from_user_id == user["id"] else r.from_user_id) in buddy_ids] + except Exception as exc: + if not _is_no_such_table_error(exc): + raise + other_ids = list({r.to_user_id if r.from_user_id == user["id"] else r.from_user_id for r in threads_rows}) user_map = {} if other_ids: @@ -7735,6 +7892,18 @@ async def api_get_thread_messages(req, env, thread_id: str): if not thread: return err("Conversation thread not found or access denied", 404) + other_id = thread.to_user_id if thread.from_user_id == user["id"] else thread.from_user_id + + try: + buddy = await env.DB.prepare( + "SELECT id FROM peer_connections WHERE ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?)) AND status='accepted'" + ).bind(user["id"], other_id, other_id, user["id"]).first() + if not buddy: + return err("You can only chat with accepted study buddies", 403) + except Exception as exc: + if not _is_no_such_table_error(exc): + raise + try: await env.DB.prepare( "UPDATE notifications SET is_read=1 WHERE user_id=? AND type='direct_message' AND related_id=?" @@ -7819,6 +7988,45 @@ async def _resolve_display_name(env, user_id: str) -> str: return getattr(row, "name", "") or getattr(row, "username", "") or "User" +async def _find_user_by_email(env, email: str): + """Resolve email by blind index, then support legacy rows with bad indexes.""" + normalized = (email or "").strip().lower() + if not normalized: + return None + + enc = env.ENCRYPTION_KEY + target_hash = blind_index(normalized, enc) + target_user = await env.DB.prepare( + "SELECT id FROM users WHERE email_hash=?" + ).bind(target_hash).first() + if target_user: + return target_user + + # Older/demo rows can have an email_hash generated with a different + # normalization or encryption key. Decrypt only on hash miss. + try: + rows = await env.DB.prepare("SELECT id, email FROM users").all() + for row in (rows.results or []): + stored_email = await decrypt_aes(getattr(row, "email", "") or "", enc) + if stored_email != "[decryption error]" and stored_email.strip().lower() == normalized: + return row + + # Local demo accounts historically used @test.com while fixtures and + # docs use @example.com. Treat example.com address as demo username + # alias, without changing real account email data. + local, domain = normalized.rsplit("@", 1) + if domain == "example.com" and local: + alias = await env.DB.prepare( + "SELECT id FROM users WHERE username_hash=?" + ).bind(blind_index(local, enc)).first() + if alias: + return alias + except Exception as exc: + if not _is_no_such_table_error(exc): + raise + return None + + async def api_send_peer_request(req, env): user = verify_token(req.headers.get("Authorization") or "", env.JWT_SECRET) if not user: @@ -7828,6 +8036,63 @@ async def api_send_peer_request(req, env): if bad_resp: return bad_resp + target_email = _legacy_text(body.get("email")).strip().lower() + if target_email or "email" in body: + neutral_msg = "We will send a request if that user exists" + if not target_email or "@" not in target_email: + return ok(None, neutral_msg) + + try: + cnt_row = await env.DB.prepare( + "SELECT COUNT(*) AS cnt FROM peer_connections" + " WHERE sender_id=? AND created_at > datetime('now', '-1 hour')" + ).bind(user["id"]).first() + if cnt_row and int(getattr(cnt_row, "cnt", 0) or 0) >= 10: + return err("Rate limit exceeded. Maximum 10 connection requests per hour.", 429) + except Exception as exc: + if not _is_no_such_table_error(exc): + raise + + target_user = await _find_user_by_email(env, target_email) + + if target_user and target_user.id != user["id"]: + recip_prof = await env.DB.prepare( + "SELECT allow_email_buddy_requests FROM user_profiles WHERE user_id=?" + ).bind(target_user.id).first() + if recip_prof and bool(getattr(recip_prof, "allow_email_buddy_requests", 0)): + existing = await env.DB.prepare( + "SELECT id, status FROM peer_connections" + " WHERE (sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?)" + ).bind(user["id"], target_user.id, target_user.id, user["id"]).first() + if not existing: + conn_id = new_id() + await env.DB.prepare( + "INSERT INTO peer_connections (id, sender_id, recipient_id, activity_id, status, created_at, updated_at)" + " VALUES (?, ?, ?, NULL, 'pending', datetime('now'), datetime('now'))" + ).bind(conn_id, user["id"], target_user.id).run() + sender_name = await _resolve_display_name(env, user["id"]) + await _create_notification( + env, target_user.id, "peer_request", + "New Study Buddy Request", + f"{sender_name} wants to connect as a study buddy.", + conn_id + ) + return ok({"sent": True}, neutral_msg) + elif getattr(existing, "status", "") == "declined": + await env.DB.prepare( + "UPDATE peer_connections SET status='pending', sender_id=?, updated_at=datetime('now') WHERE id=?" + ).bind(user["id"], existing.id).run() + sender_name = await _resolve_display_name(env, user["id"]) + await _create_notification( + env, target_user.id, "peer_request", + "New Study Buddy Request", + f"{sender_name} wants to connect as a study buddy.", + existing.id + ) + return ok({"sent": True}, neutral_msg) + + return ok(None, neutral_msg) + recipient_id = _legacy_text(body.get("recipient_id")).strip() activity_id = _legacy_text(body.get("activity_id")).strip() if not recipient_id or not activity_id: @@ -7860,6 +8125,12 @@ async def api_send_peer_request(req, env): if not recip_enrolled and not recip_is_host: return err("Recipient is not part of this activity", 403) + recip_prof = await env.DB.prepare( + "SELECT allow_buddy_requests FROM user_profiles WHERE user_id=?" + ).bind(recipient_id).first() + if not recip_prof or not bool(getattr(recip_prof, "allow_buddy_requests", 0)): + return err("This user is not accepting buddy requests", 403) + try: cnt_row = await env.DB.prepare( "SELECT COUNT(*) AS cnt FROM peer_connections" @@ -7947,6 +8218,24 @@ async def api_respond_peer_request(req, env, connection_id: str): return ok({"id": connection_id, "status": new_status}, f"Connection request {new_status}") +async def api_delete_peer_connection(req, env, connection_id: str): + user = verify_token(req.headers.get("Authorization") or "", env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + + conn = await env.DB.prepare( + "SELECT id, sender_id, recipient_id FROM peer_connections WHERE id=? AND (sender_id=? OR recipient_id=?)" + ).bind(connection_id, user["id"], user["id"]).first() + if not conn: + return err("Connection not found", 404) + + await env.DB.prepare( + "DELETE FROM peer_connections WHERE id=?" + ).bind(connection_id).run() + + return ok({"id": connection_id, "deleted": True}, "Study buddy removed") + + async def api_list_peer_connections(req, env): user = verify_token(req.headers.get("Authorization") or "", env.JWT_SECRET) if not user: @@ -7967,10 +8256,13 @@ async def api_list_peer_connections(req, env): for row in (accepted_res.results or []): other_id = row.recipient_id if row.sender_id == user["id"] else row.sender_id other_name = await _resolve_display_name(env, other_id) - act_row = await env.DB.prepare( - "SELECT title FROM activities WHERE id=?" - ).bind(row.activity_id).first() - act_title = getattr(act_row, "title", "") if act_row else "" + act_title = "Direct Connection" + if row.activity_id: + act_row = await env.DB.prepare( + "SELECT title FROM activities WHERE id=?" + ).bind(row.activity_id).first() + if act_row and getattr(act_row, "title", None): + act_title = act_row.title connections.append({ "id": row.id, "user_id": other_id, @@ -7989,10 +8281,13 @@ async def api_list_peer_connections(req, env): for row in (pending_res.results or []): sender_name = await _resolve_display_name(env, row.sender_id) - act_row = await env.DB.prepare( - "SELECT title FROM activities WHERE id=?" - ).bind(row.activity_id).first() - act_title = getattr(act_row, "title", "") if act_row else "" + act_title = "Direct Connection" + if row.activity_id: + act_row = await env.DB.prepare( + "SELECT title FROM activities WHERE id=?" + ).bind(row.activity_id).first() + if act_row and getattr(act_row, "title", None): + act_title = act_row.title pending_received.append({ "id": row.id, "sender_id": row.sender_id, @@ -8013,6 +8308,16 @@ async def api_peer_connection_status(req, env, target_user_id: str): if not user: return err("Authentication required", 401) + allow_buddy = False + try: + prof = await env.DB.prepare( + "SELECT allow_buddy_requests FROM user_profiles WHERE user_id=?" + ).bind(target_user_id).first() + if prof and bool(getattr(prof, "allow_buddy_requests", 0)): + allow_buddy = True + except Exception: + pass + try: row = await env.DB.prepare( "SELECT id, status, sender_id, recipient_id FROM peer_connections" @@ -8020,16 +8325,17 @@ async def api_peer_connection_status(req, env, target_user_id: str): ).bind(user["id"], target_user_id, target_user_id, user["id"]).first() except Exception as exc: if _is_no_such_table_error(exc): - return ok({"status": "none", "connection_id": None}) + return ok({"status": "none", "connection_id": None, "allow_buddy_requests": allow_buddy}) raise if not row: - return ok({"status": "none", "connection_id": None}) + return ok({"status": "none", "connection_id": None, "allow_buddy_requests": allow_buddy}) return ok({ "status": row.status, "connection_id": row.id, "is_sender": row.sender_id == user["id"], + "allow_buddy_requests": allow_buddy, }) @@ -8354,6 +8660,8 @@ async def _dispatch(request, env): return await api_activity_calendar_ics(m_act_cal.group(1), request, env) m = re.fullmatch(r"/api/activities/([A-Za-z0-9_-]+)", path) + if m and method == "DELETE": + return await api_delete_activity(m.group(1), request, env) if m and method == "GET": return await api_get_activity(m.group(1), request, env) @@ -8490,13 +8798,16 @@ async def _dispatch(request, env): # --- END NEW --- # --- Peer Connections (Study Buddy Network) --- - if path == "/api/peers/request" and method == "POST": + if path in ("/api/peers/request", "/api/peers/request-by-email") and method == "POST": return await api_send_peer_request(request, env) if path == "/api/peers" and method == "GET": return await api_list_peer_connections(request, env) m_peer_respond = re.fullmatch(r"/api/peers/([A-Za-z0-9_-]+)/respond", path) if m_peer_respond and method == "PATCH": return await api_respond_peer_request(request, env, m_peer_respond.group(1)) + m_peer_del = re.fullmatch(r"/api/peers/([A-Za-z0-9_-]+)", path) + if m_peer_del and method == "DELETE": + return await api_delete_peer_connection(request, env, m_peer_del.group(1)) m_peer_status = re.fullmatch(r"/api/peers/status/([A-Za-z0-9_-]+)", path) if m_peer_status and method == "GET": return await api_peer_connection_status(request, env, m_peer_status.group(1)) diff --git a/tests/test_api_activities.py b/tests/test_api_activities.py index a3109aa..bd049f9 100644 --- a/tests/test_api_activities.py +++ b/tests/test_api_activities.py @@ -215,6 +215,7 @@ async def test_missing_title_returns_400(self): r = await worker.api_create_activity(self._req({"description": "no title"}, token), env) assert r.status == 400 + async def test_successful_creation(self): token = _make_host_token() env = make_env(db=MockDB([make_stmt()])) # INSERT run() @@ -290,6 +291,30 @@ async def test_invalid_json_returns_400(self): assert r.status == 400 +class TestApiDeleteActivity: + def _req(self, activity_id="act-1", token=None): + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + return MockRequest(method="DELETE", url=f"http://localhost/api/activities/{activity_id}", headers=headers) + + async def test_delete_requires_host(self): + token = _make_host_token(uid="member-1") + env = make_env(db=MockDB([make_stmt(first=MockRow(id="act-1", host_id="host-1"))])) + r = await worker.api_delete_activity("act-1", self._req(token=token), env) + assert r.status == 403 + + async def test_delete_host_cleans_activity(self): + token = _make_host_token(uid="host-1") + env = make_env(db=MockDB([ + make_stmt(first=MockRow(id="act-1", host_id="host-1")), + *[make_stmt() for _ in range(12)], + ])) + r = await worker.api_delete_activity("act-1", self._req(token=token), env) + assert r.status == 200 + assert _parse(r)["data"]["deleted"] is True + + # --------------------------------------------------------------------------- # api_get_activity() # --------------------------------------------------------------------------- diff --git a/tests/test_api_profile.py b/tests/test_api_profile.py index b47fe23..5ecac8d 100644 --- a/tests/test_api_profile.py +++ b/tests/test_api_profile.py @@ -108,6 +108,14 @@ async def test_returns_profile_fields(self): assert d["github_username"] == "alice-gh" assert d["is_teacher"] is False assert d["is_profile_public"] is False + assert d["allow_buddy_requests"] is False + + async def test_returns_allow_buddy_requests_true(self): + row = _profile_row(allow_buddy_requests=1) + env = make_env(db=MockDB([*_ensure_profile_stmts(), make_stmt(first=row)])) + r = await worker.api_profile(self._req(), env) + d = _parse(r)["profile"] + assert d["allow_buddy_requests"] is True async def test_null_optional_fields_return_empty_string(self): row = _profile_row() @@ -157,6 +165,12 @@ async def test_is_teacher_coerced_to_int(self): ) assert r.status == 200 + async def test_patch_allow_buddy_requests_standalone(self): + env = make_env(db=MockDB([*_ensure_profile_stmts(), make_stmt()])) + r = await worker.api_profile(self._req({"allow_buddy_requests": True}), env) + assert r.status == 200 + assert _parse(r)["success"] is True + # --------------------------------------------------------------------------- # DELETE /api/profile (anonymizing account deletion) diff --git a/tests/test_messaging.py b/tests/test_messaging.py index a889fc7..f457229 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -80,8 +80,10 @@ async def test_list_message_threads(self): token = _make_token(uid="usr-1") threads_rows = [MockRow(id="th-1", from_user_id="usr-1", to_user_id="usr-2", created_at="2026-01-01", source="email", activity_id=None)] other_user = MockRow(id="usr-2", name="Bob Martinez", username="bob") + buddy_row = MockRow(sender_id="usr-1", recipient_id="usr-2") env = make_env(db=MockDB([ make_stmt(all_results=threads_rows), + make_stmt(all_results=[buddy_row]), # peer_connections query make_stmt(all_results=[other_user]), make_stmt(first=None), # unread check ])) @@ -94,9 +96,12 @@ async def test_list_message_threads(self): async def test_send_thread_message(self): token = _make_token(uid="usr-1") thread_row = MockRow(id="th-1", from_user_id="usr-1", to_user_id="usr-2", status="accepted") + buddy_row = MockRow(id="conn-1") env = make_env(db=MockDB([ make_stmt(first=thread_row), # SELECT thread + make_stmt(first=buddy_row), # SELECT peer_connections check make_stmt(), # INSERT legacy_records + make_stmt(first=None), # SELECT sender display name make_stmt(), # notification ])) r = await worker.api_send_thread_message( @@ -107,3 +112,37 @@ async def test_send_thread_message(self): data = _parse(r) assert data["data"]["content"] == "Hello!" + async def test_send_thread_message_blocked_when_not_buddies(self): + token = _make_token(uid="usr-1") + thread_row = MockRow(id="th-1", from_user_id="usr-1", to_user_id="usr-2", status="accepted") + env = make_env(db=MockDB([ + make_stmt(first=thread_row), # SELECT thread + make_stmt(first=None), # SELECT peer_connections check (not buddies) + ])) + r = await worker.api_send_thread_message( + self._req("POST", "/api/messages/threads/th-1/send", {"message": "Hello!"}, token=token), + env, "th-1" + ) + assert r.status == 403 + data = _parse(r) + assert "study buddies" in data["error"] + + async def test_activity_message_blocked_when_not_buddies(self): + token = _make_token(uid="usr-1") + act_row = MockRow(id="act-1", host_id="usr-host") + enroll_row = MockRow(id="enr-1") + env = make_env(db=MockDB([ + make_stmt(first=act_row), # act lookup + make_stmt(first=enroll_row), # sender enrolled + make_stmt(first=enroll_row), # target enrolled + make_stmt(first=None), # peer_connections check (not buddies) + ])) + r = await worker.api_send_activity_message_request( + self._req("POST", "/api/activities/act-1/message/usr-2", token=token), + env, "act-1", "usr-2" + ) + assert r.status == 403 + data = _parse(r) + assert "study buddies" in data["error"] + + diff --git a/tests/test_peer_connections.py b/tests/test_peer_connections.py index db4cd84..f0b2a5a 100644 --- a/tests/test_peer_connections.py +++ b/tests/test_peer_connections.py @@ -92,6 +92,23 @@ async def test_send_request_recipient_not_enrolled(self): assert r.status == 403 assert "not part of" in _parse(r)["error"].lower() + async def test_send_request_recipient_disallows_buddy_requests(self): + token = _make_token(uid="usr-1") + env = make_env(db=MockDB([ + make_stmt(first=MockRow(id="act-1")), + make_stmt(first=MockRow(id="enr-1")), + make_stmt(first=None), + make_stmt(first=MockRow(id="enr-2")), + make_stmt(first=None), + make_stmt(first=MockRow(allow_buddy_requests=0)), + ])) + r = await worker.api_send_peer_request( + self._req("POST", "/api/peers/request", {"recipient_id": "usr-2", "activity_id": "act-1"}, token=token), + env, + ) + assert r.status == 403 + assert "not accepting buddy requests" in _parse(r)["error"].lower() + async def test_send_request_success(self): token = _make_token(uid="usr-1") env = make_env(db=MockDB([ @@ -100,6 +117,7 @@ async def test_send_request_success(self): make_stmt(first=None), make_stmt(first=MockRow(id="enr-2")), make_stmt(first=None), + make_stmt(first=MockRow(allow_buddy_requests=1)), make_stmt(first=MockRow(cnt=0)), make_stmt(first=None), make_stmt(), @@ -124,6 +142,7 @@ async def test_send_request_already_connected(self): make_stmt(first=None), make_stmt(first=MockRow(id="enr-2")), make_stmt(first=None), + make_stmt(first=MockRow(allow_buddy_requests=1)), make_stmt(first=MockRow(cnt=0)), make_stmt(first=MockRow(id="conn-1", status="accepted")), ])) @@ -141,6 +160,7 @@ async def test_send_request_rate_limited(self): make_stmt(first=None), make_stmt(first=MockRow(id="enr-2")), make_stmt(first=None), + make_stmt(first=MockRow(allow_buddy_requests=1)), make_stmt(first=MockRow(cnt=10)), ])) r = await worker.api_send_peer_request( @@ -257,6 +277,7 @@ async def test_status_unauthenticated(self): async def test_status_none(self): token = _make_token(uid="usr-1") env = make_env(db=MockDB([ + make_stmt(first=MockRow(allow_buddy_requests=0)), make_stmt(first=None), ])) r = await worker.api_peer_connection_status( @@ -265,10 +286,12 @@ async def test_status_none(self): assert r.status == 200 data = _parse(r) assert data["data"]["status"] == "none" + assert data["data"]["allow_buddy_requests"] is False async def test_status_connected(self): token = _make_token(uid="usr-1") env = make_env(db=MockDB([ + make_stmt(first=MockRow(allow_buddy_requests=1)), make_stmt(first=MockRow(id="conn-1", status="accepted", sender_id="usr-1", recipient_id="usr-2")), ])) r = await worker.api_peer_connection_status( @@ -278,3 +301,129 @@ async def test_status_connected(self): data = _parse(r) assert data["data"]["status"] == "accepted" assert data["data"]["is_sender"] is True + assert data["data"]["allow_buddy_requests"] is True + + async def test_delete_connection_unauthenticated(self): + env = make_env() + r = await worker.api_delete_peer_connection( + self._req("DELETE", "/api/peers/conn-1"), env, "conn-1" + ) + assert r.status == 401 + + async def test_delete_connection_not_found(self): + token = _make_token(uid="usr-1") + env = make_env(db=MockDB([ + make_stmt(first=None), + ])) + r = await worker.api_delete_peer_connection( + self._req("DELETE", "/api/peers/conn-1", token=token), env, "conn-1" + ) + assert r.status == 404 + + async def test_delete_connection_success(self): + token = _make_token(uid="usr-1") + env = make_env(db=MockDB([ + make_stmt(first=MockRow(id="conn-1", sender_id="usr-1", recipient_id="usr-2")), + make_stmt(), + ])) + r = await worker.api_delete_peer_connection( + self._req("DELETE", "/api/peers/conn-1", token=token), env, "conn-1" + ) + assert r.status == 200 + data = _parse(r) + assert data["data"]["deleted"] is True + + async def test_send_peer_request_by_email_success(self): + token = _make_token(uid="usr-1") + target_user = MockRow(id="usr-2") + target_prof = MockRow(allow_email_buddy_requests=1) + sender_row = MockRow(name="Alice", username="alice") + env = make_env(db=MockDB([ + make_stmt(first=MockRow(cnt=0)), # rate limit check + make_stmt(first=target_user), # lookup target user + make_stmt(first=target_prof), # lookup target profile + make_stmt(first=None), # check existing connection + make_stmt(), # insert peer connection + make_stmt(first=sender_row), # resolve sender display name + make_stmt(), # create notification + ])) + r = await worker.api_send_peer_request( + self._req("POST", "/api/peers/request", {"email": "bob@example.com"}, token=token), + env, + ) + assert r.status == 200 + data = _parse(r) + assert data["message"] == "We will send a request if that user exists" + + async def test_send_peer_request_by_email_target_toggle_off(self): + token = _make_token(uid="usr-1") + target_user = MockRow(id="usr-2") + target_prof = MockRow(allow_email_buddy_requests=0) # toggle off! + env = make_env(db=MockDB([ + make_stmt(first=MockRow(cnt=0)), # rate limit check + make_stmt(first=target_user), # lookup target user + make_stmt(first=target_prof), # lookup target profile + ])) + r = await worker.api_send_peer_request( + self._req("POST", "/api/peers/request", {"email": "bob@example.com"}, token=token), + env, + ) + assert r.status == 200 + data = _parse(r) + # Does not confirm whether sent or user exists: + assert data["message"] == "We will send a request if that user exists" + + async def test_send_peer_request_by_email_nonexistent_user(self): + token = _make_token(uid="usr-1") + env = make_env(db=MockDB([ + make_stmt(first=MockRow(cnt=0)), # rate limit check + make_stmt(first=None), # user lookup returns none + ])) + r = await worker.api_send_peer_request( + self._req("POST", "/api/peers/request", {"email": "unknown@example.com"}, token=token), + env, + ) + assert r.status == 200 + data = _parse(r) + assert data["message"] == "We will send a request if that user exists" + + async def test_send_peer_request_by_email_self(self): + token = _make_token(uid="usr-1") + env = make_env(db=MockDB([ + make_stmt(first=MockRow(cnt=0)), # rate limit check + make_stmt(first=MockRow(id="usr-1")), # lookup returns self + ])) + r = await worker.api_send_peer_request( + self._req("POST", "/api/peers/request", {"email": "myself@example.com"}, token=token), + env, + ) + assert r.status == 200 + data = _parse(r) + assert data["message"] == "We will send a request if that user exists" + + async def test_send_peer_request_by_email_rate_limited(self): + token = _make_token(uid="usr-1") + env = make_env(db=MockDB([ + make_stmt(first=MockRow(cnt=10)), # rate limit exceeded + ])) + r = await worker.api_send_peer_request( + self._req("POST", "/api/peers/request", {"email": "target@example.com"}, token=token), + env, + ) + assert r.status == 429 + assert "Rate limit" in _parse(r)["error"] + + async def test_profile_patch_allow_email_buddy_requests(self): + token = _make_token(uid="usr-1") + env = make_env(db=MockDB([ + make_stmt(), # ensure user profile + make_stmt(), # update user_profiles SET allow_email_buddy_requests=? + ])) + r = await worker.api_profile( + self._req("PATCH", "/api/profile", {"allow_email_buddy_requests": True}, token=token), + env, + ) + assert r.status == 200 + data = _parse(r) + assert data["data"]["allow_email_buddy_requests"] is True +