Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions migrations/0014_add_peer_connections.sql
Original file line number Diff line number Diff line change
@@ -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);
3 changes: 3 additions & 0 deletions migrations/0015_add_allow_buddy_requests.sql
Original file line number Diff line number Diff line change
@@ -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;
27 changes: 27 additions & 0 deletions migrations/0016_add_allow_email_buddy_requests.sql
Original file line number Diff line number Diff line change
@@ -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);
149 changes: 143 additions & 6 deletions public/activity-detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,20 @@ <h2 class="text-2xl font-bold text-gray-800 dark:text-white mb-2 flex items-cent
<div id="activity-contacts-list" class="space-y-3">
<div class="text-sm text-gray-500 dark:text-gray-400 italic">Loading contacts...</div>
</div>
<!-- Add Buddy by Email -->
<div class="mt-5 pt-5 border-t border-gray-200 dark:border-gray-700">
<h3 class="text-sm font-bold text-gray-800 dark:text-gray-200 flex items-center gap-2 mb-2">
<i class="fa-solid fa-envelope text-teal-500"></i>Add Buddy by Email
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Know someone's email? Send them a buddy request directly.</p>
<form id="add-peer-email-form" class="flex flex-col sm:flex-row gap-2">
<input type="email" id="peer-email-input" required placeholder="Enter exact email address..." class="flex-1 px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-teal-500 focus:outline-none">
<button type="submit" id="peer-email-submit-btn" class="inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-semibold rounded-lg bg-teal-600 text-white hover:bg-teal-700 transition shadow-sm">
<i class="fa-solid fa-paper-plane text-xs"></i><span>Send</span>
</button>
</form>
<p id="peer-email-feedback" class="text-xs text-gray-500 dark:text-gray-400 mt-1.5 min-h-[1rem]" role="status" aria-live="polite"></p>
</div>
</section>

<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden border border-gray-100 dark:border-gray-700">
Expand Down Expand Up @@ -695,24 +709,105 @@ <h2 class="text-xl font-bold mb-2">Prerequisites</h2>
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 = '<a href="/messages" class="inline-flex items-center px-3 py-1.5 text-xs font-semibold rounded-lg bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-200 hover:bg-teal-200 transition"><i class="fa-solid fa-comments mr-1.5"></i>View conversation</a>';
} else if (c.thread_status === 'pending') {
actionBtn = '<span class="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"><i class="fa-solid fa-clock mr-1.5"></i>Request pending</span>';
if (isBuddy) {
if (c.thread_status === 'accepted') {
actionBtn = '<a href="/messages" class="inline-flex items-center px-3 py-1.5 text-xs font-semibold rounded-lg bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-200 hover:bg-teal-200 transition"><i class="fa-solid fa-comments mr-1.5"></i>View conversation</a>';
} else if (c.thread_status === 'pending') {
actionBtn = '<span class="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"><i class="fa-solid fa-clock mr-1.5"></i>Request pending</span>';
} else {
actionBtn = '<button type="button" data-user-id="' + esc(c.user_id) + '" class="act-msg-btn inline-flex items-center px-3 py-1.5 text-xs font-semibold rounded-lg bg-teal-600 text-white hover:bg-teal-700 transition"><i class="fa-solid fa-paper-plane mr-1.5"></i>Message</button>';
}
}

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 = '<span class="inline-flex items-center px-3 py-1.5 text-xs font-semibold rounded-lg bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200"><i class="fa-solid fa-user-group mr-1.5"></i>Connected</span>';
} else if (ps && ps.status === 'pending') {
if (ps.is_sender) {
peerBtn = '<span class="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"><i class="fa-solid fa-clock mr-1.5"></i>Pending</span>';
} else {
peerBtn = '<button type="button" data-conn-id="' + esc(ps.connection_id) + '" data-action="accept" class="peer-detail-respond-btn inline-flex items-center px-2.5 py-1.5 text-xs font-semibold rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 transition mr-1"><i class="fa-solid fa-check mr-1"></i>Accept</button>' +
'<button type="button" data-conn-id="' + esc(ps.connection_id) + '" data-action="decline" class="peer-detail-respond-btn inline-flex items-center px-2.5 py-1.5 text-xs font-semibold rounded-lg bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-300 transition"><i class="fa-solid fa-times mr-1"></i>Decline</button>';
}
} else if (allowsBuddy) {
peerBtn = '<button type="button" data-user-id="' + esc(c.user_id) + '" class="peer-connect-btn inline-flex items-center px-3 py-1.5 text-xs font-semibold rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition"><i class="fa-solid fa-user-plus mr-1.5"></i>Study Buddy</button>';
} else {
actionBtn = '<button type="button" data-user-id="' + esc(c.user_id) + '" class="act-msg-btn inline-flex items-center px-3 py-1.5 text-xs font-semibold rounded-lg bg-teal-600 text-white hover:bg-teal-700 transition"><i class="fa-solid fa-paper-plane mr-1.5"></i>Message</button>';
peerBtn = '';
}

return '<div class="flex items-center justify-between p-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">' +
'<div><div class="text-sm font-bold text-gray-800 dark:text-gray-200">' + esc(c.display_name) + '</div></div>' +
'<div>' + actionBtn + '</div>' +
'<div class="flex gap-2">' + peerBtn + actionBtn + '</div>' +
'</div>';
}).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 = '<i class="fa-solid fa-spinner fa-spin mr-1.5"></i>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 = '<i class="fa-solid fa-clock mr-1.5"></i>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 = '<i class="fa-solid fa-user-plus mr-1.5"></i>Study Buddy';
}
});
});

list.querySelectorAll('.act-msg-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const targetId = btn.getAttribute('data-user-id');
Expand Down Expand Up @@ -740,6 +835,48 @@ <h2 class="text-xl font-bold mb-2">Prerequisites</h2>
}
});
});

// 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 = '<i class="fa-solid fa-spinner fa-spin text-xs"></i> 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 = '<i class="fa-solid fa-paper-plane text-xs"></i><span>Send</span>';
}
});
}
} catch (err) {
card.classList.add('hidden');
}
Expand Down
Loading
Loading