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/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 2dec498..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.
+
+
+
@@ -695,24 +709,105 @@ 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 => {
+ 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 ';
+ if (isBuddy) {
+ if (c.thread_status === 'accepted') {
+ actionBtn = 'View conversation ';
+ } else if (c.thread_status === 'pending') {
+ actionBtn = ' Request pending ';
+ } else {
+ actionBtn = ' Message ';
+ }
+ }
+
+ let peerBtn = '';
+ 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') {
+ if (ps.is_sender) {
+ peerBtn = ' Pending ';
+ } else {
+ peerBtn = ' Accept ' +
+ ' Decline ';
+ }
+ } else if (allowsBuddy) {
+ peerBtn = ' Study Buddy ';
} else {
- actionBtn = ' Message ';
+ peerBtn = '';
}
return '' +
'
' + esc(c.display_name) + '
' +
- '
' + actionBtn + '
' +
+ '
' + peerBtn + actionBtn + '
' +
'
';
}).join('');
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');
+ 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');
@@ -740,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 40da9ce..cd6af9b 100644
--- a/public/dashboard.html
+++ b/public/dashboard.html
@@ -156,6 +156,9 @@ Manage who can send you study buddy connection requests across your activities and via email.
+
+
+
+
+ Allow buddy reqs from the same activity peers
+
+
+
+
+
+
+
+
+
+ Allow buddy requests if someone knows my email
+
+
+
+
+
+
+
+
+
+
+
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') + '
' +
+ '
Accept ' +
+ 'Decline
'
+ ).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
+
+ Delete
+
`;
}).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 95ff25d..8019b27 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,
+ 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 f66a112..da142a4 100644
--- a/src/worker.py
+++ b/src/worker.py
@@ -1113,6 +1113,23 @@ async def send_password_change_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,
+ 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)",
]
@@ -1163,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:
@@ -1209,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,
@@ -1428,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)
@@ -1487,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"]
@@ -1707,7 +1750,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):
@@ -1796,7 +1839,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.",
@@ -2277,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,
})
@@ -2284,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]
@@ -2380,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
@@ -2389,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"
@@ -2401,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"
@@ -3199,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()
@@ -3219,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),
@@ -3231,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]
@@ -3240,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:
@@ -3248,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),
@@ -3259,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:
@@ -5951,7 +6083,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,"
@@ -7525,6 +7659,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()
@@ -7536,6 +7671,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(
@@ -7547,6 +7688,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})
@@ -7562,25 +7704,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()
@@ -7630,6 +7782,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()
@@ -7680,6 +7843,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:
@@ -7730,6 +7906,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=?"
@@ -7793,6 +7981,378 @@ 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 _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:
+ return err("Authentication required", 401)
+
+ body, bad_resp = await parse_json_object(req)
+ 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:
+ 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)
+
+ 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"
+ " 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_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:
+ 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_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,
+ "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_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,
+ "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)
+
+ 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"
+ " 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, "allow_buddy_requests": allow_buddy})
+ raise
+
+ if not row:
+ 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,
+ })
+
+
# ---------------------------------------------------------------------------
# Profile & user-directory API handlers
# ---------------------------------------------------------------------------
@@ -8114,6 +8674,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)
@@ -8249,6 +8811,21 @@ 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 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))
+
# Notifications
if path == "/api/notifications" and method == "GET":
return await api_list_notifications(request, env)
@@ -8331,8 +8908,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_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
new file mode 100644
index 0000000..f0b2a5a
--- /dev/null
+++ b/tests/test_peer_connections.py
@@ -0,0 +1,429 @@
+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_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([
+ 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=1)),
+ 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(allow_buddy_requests=1)),
+ 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(allow_buddy_requests=1)),
+ 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=MockRow(allow_buddy_requests=0)),
+ 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"
+ 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(
+ 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
+ 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
+