From 1097c0912363bb4d2709aac55ca00714f4e1daf3 Mon Sep 17 00:00:00 2001 From: Ananya Date: Sun, 5 Jul 2026 19:40:55 +0530 Subject: [PATCH 1/2] study groups --- migrations/0011_add_study_groups.sql | 47 +++ public/invitations.html | 119 ++++++ public/partials/navbar.html | 5 +- public/study-group-detail.html | 179 ++++++++ public/study-groups.html | 242 +++++++++++ schema.sql | 47 +++ src/study_groups.py | 607 +++++++++++++++++++++++++++ src/worker.py | 89 ++++ tests/test_study_groups_api.py | 286 +++++++++++++ 9 files changed, 1620 insertions(+), 1 deletion(-) create mode 100644 migrations/0011_add_study_groups.sql create mode 100644 public/invitations.html create mode 100644 public/study-group-detail.html create mode 100644 public/study-groups.html create mode 100644 src/study_groups.py create mode 100644 tests/test_study_groups_api.py diff --git a/migrations/0011_add_study_groups.sql b/migrations/0011_add_study_groups.sql new file mode 100644 index 0000000..38c7ff7 --- /dev/null +++ b/migrations/0011_add_study_groups.sql @@ -0,0 +1,47 @@ +-- Migration 0011: Add study groups, members, and invites + +CREATE TABLE IF NOT EXISTS study_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + activity_id TEXT, + creator_id TEXT NOT NULL, + max_members INTEGER NOT NULL DEFAULT 10, + is_private INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE SET NULL, + FOREIGN KEY (creator_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS study_group_members ( + id TEXT PRIMARY KEY, + group_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + joined_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (group_id, user_id), + FOREIGN KEY (group_id) REFERENCES study_groups(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS study_group_invites ( + id TEXT PRIMARY KEY, + group_id TEXT NOT NULL, + inviter_id TEXT NOT NULL, + invitee_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 (group_id, invitee_id), + FOREIGN KEY (group_id) REFERENCES study_groups(id) ON DELETE CASCADE, + FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (invitee_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_sg_activity ON study_groups(activity_id); +CREATE INDEX IF NOT EXISTS idx_sg_creator ON study_groups(creator_id); +CREATE INDEX IF NOT EXISTS idx_sgm_group ON study_group_members(group_id); +CREATE INDEX IF NOT EXISTS idx_sgm_user ON study_group_members(user_id); +CREATE INDEX IF NOT EXISTS idx_sgi_group ON study_group_invites(group_id); +CREATE INDEX IF NOT EXISTS idx_sgi_invitee_status ON study_group_invites(invitee_id, status); diff --git a/public/invitations.html b/public/invitations.html new file mode 100644 index 0000000..a8e5608 --- /dev/null +++ b/public/invitations.html @@ -0,0 +1,119 @@ + + + + + + + Study Group Invitations - Alpha One Labs + + + + + + + + +
+

Pending Invitations

+
+
+ + + + + + diff --git a/public/partials/navbar.html b/public/partials/navbar.html index acaf410..6bb226c 100644 --- a/public/partials/navbar.html +++ b/public/partials/navbar.html @@ -48,7 +48,7 @@ Whiteboard - + Study Groups @@ -278,6 +278,9 @@ Courses + + Study Groups + diff --git a/public/study-group-detail.html b/public/study-group-detail.html new file mode 100644 index 0000000..c649e51 --- /dev/null +++ b/public/study-group-detail.html @@ -0,0 +1,179 @@ + + + + + + + Study Group Detail - Alpha One Labs + + + + + + + + +
+
+ +
+ + + + + + diff --git a/public/study-groups.html b/public/study-groups.html new file mode 100644 index 0000000..c7aa373 --- /dev/null +++ b/public/study-groups.html @@ -0,0 +1,242 @@ + + + + + + + Study Groups - Alpha One Labs + + + + + + + + +
+
+

Study Groups

+
+ + Invitations + + + +
+
+ + + +
+
+ + +
+
+ +
+

Public Groups

+
+
+ +
+

My Groups

+
+
+
+ + + + + + diff --git a/schema.sql b/schema.sql index 2c58c6b..2418d8d 100644 --- a/schema.sql +++ b/schema.sql @@ -95,6 +95,53 @@ CREATE INDEX IF NOT EXISTS idx_sa_session ON session_attendance(sessio CREATE INDEX IF NOT EXISTS idx_sa_user ON session_attendance(user_id); CREATE INDEX IF NOT EXISTS idx_at_activity ON activity_tags(activity_id); +-- STUDY GROUPS +CREATE TABLE IF NOT EXISTS study_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + activity_id TEXT, + creator_id TEXT NOT NULL, + max_members INTEGER NOT NULL DEFAULT 10, + is_private INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE SET NULL, + FOREIGN KEY (creator_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS study_group_members ( + id TEXT PRIMARY KEY, + group_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', -- creator | member + joined_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (group_id, user_id), + FOREIGN KEY (group_id) REFERENCES study_groups(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS study_group_invites ( + id TEXT PRIMARY KEY, + group_id TEXT NOT NULL, + inviter_id TEXT NOT NULL, + invitee_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', -- pending | accepted | declined + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (group_id, invitee_id), + FOREIGN KEY (group_id) REFERENCES study_groups(id) ON DELETE CASCADE, + FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (invitee_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_sg_activity ON study_groups(activity_id); +CREATE INDEX IF NOT EXISTS idx_sg_creator ON study_groups(creator_id); +CREATE INDEX IF NOT EXISTS idx_sgm_group ON study_group_members(group_id); +CREATE INDEX IF NOT EXISTS idx_sgm_user ON study_group_members(user_id); +CREATE INDEX IF NOT EXISTS idx_sgi_group ON study_group_invites(group_id); +CREATE INDEX IF NOT EXISTS idx_sgi_invitee_status ON study_group_invites(invitee_id, status); + -- NOTIFICATIONS CREATE TABLE IF NOT EXISTS notifications ( id TEXT PRIMARY KEY, diff --git a/src/study_groups.py b/src/study_groups.py new file mode 100644 index 0000000..1d4f0d0 --- /dev/null +++ b/src/study_groups.py @@ -0,0 +1,607 @@ +import re +from urllib.parse import urlparse, parse_qs + + +def _required_text(value): + if value is None: + return None + txt = str(value).strip() + return txt or None + + +def _optional_text(value): + if value is None: + return "" + txt = str(value).strip() + return txt + + +async def _auth(request, env, helpers): + user = helpers["verify_token"](request.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return None, helpers["err"]("Authentication required", 401) + if not isinstance(user, dict): + return None, helpers["err"]("Authentication required", 401) + + user_id = _required_text(user.get("id")) + if not user_id: + return None, helpers["err"]("Authentication required", 401) + + user["id"] = user_id + return user, None + + +async def _notify(helpers, env, user_id: str, title: str, message: str, related_id: str = None): + try: + await helpers["_create_notification"]( + env, + user_id, + "info", + title, + message, + related_id=related_id, + category="system", + ) + except Exception: + return None + return None + + +async def _run_batch(env, statements): + if hasattr(env.DB, "batch"): + return await env.DB.batch(statements) + for stmt in statements: + await stmt.run() + return None + + +async def _lookup_user(env, helpers, identifier: str): + identifier = (identifier or "").strip() + if not identifier: + return None + + if "@" in identifier: + email_hash = helpers["blind_index"](identifier, env.ENCRYPTION_KEY) + return await env.DB.prepare( + "SELECT id, username FROM users WHERE email_hash=?" + ).bind(email_hash).first() + + username_hash = helpers["blind_index"](identifier, env.ENCRYPTION_KEY) + return await env.DB.prepare( + "SELECT id, username FROM users WHERE username_hash=?" + ).bind(username_hash).first() + + +async def _list_groups(request, env, helpers): + user, bad = await _auth(request, env, helpers) + if bad: + return bad + + params = parse_qs(urlparse(request.url).query) + activity_id = _optional_text((params.get("activity_id") or [""])[0]) + + sql = ( + "SELECT g.id,g.name,g.description,g.activity_id,g.creator_id,g.max_members," + "g.is_private,g.created_at,g.updated_at," + "COUNT(m.id) AS member_count," + "CASE WHEN EXISTS(SELECT 1 FROM study_group_members sm2" + " WHERE sm2.group_id=g.id AND sm2.user_id=?)" + " THEN 1 ELSE 0 END AS is_member," + "CASE WHEN g.creator_id=? THEN 1 ELSE 0 END AS is_creator" + " FROM study_groups g" + " LEFT JOIN study_group_members m ON m.group_id=g.id" + " WHERE (g.is_private=0 OR EXISTS(SELECT 1 FROM study_group_members sm" + " WHERE sm.group_id=g.id AND sm.user_id=?))" + ) + bind_args = [user["id"], user["id"], user["id"]] + if activity_id: + sql += " AND g.activity_id=?" + bind_args.append(activity_id) + sql += " GROUP BY g.id ORDER BY g.created_at DESC" + + try: + rows = await env.DB.prepare(sql).bind(*bind_args).all() + except Exception as exc: + print(f"[study_groups._list_groups.bind] bind_args={bind_args!r} error={exc}") + return helpers["err"]("Failed to list study groups", 500) + + groups = [] + for r in rows.results or []: + groups.append({ + "id": r.id, + "name": r.name, + "description": r.description, + "activity_id": r.activity_id, + "creator_id": r.creator_id, + "max_members": r.max_members, + "is_private": bool(r.is_private), + "member_count": r.member_count, + "is_member": bool(r.is_member), + "is_creator": bool(r.is_creator), + "created_at": r.created_at, + "updated_at": r.updated_at, + }) + + return helpers["ok"]({"groups": groups}) + + +async def _create_group(request, env, helpers): + user, bad = await _auth(request, env, helpers) + if bad: + return bad + + body, bad_resp = await helpers["parse_json_object"](request) + if bad_resp: + return bad_resp + + name = (body.get("name") or "").strip() + description = _optional_text(body.get("description")) + activity_id = _optional_text(body.get("activity_id")) + max_members = body.get("max_members", 10) + is_private = body.get("is_private", False) + + if not name: + return helpers["err"]("name is required") + + try: + max_members = int(max_members) + except Exception: + return helpers["err"]("max_members must be an integer") + + if max_members < 2: + return helpers["err"]("max_members must be at least 2") + + group_id = helpers["new_id"]() + member_id = helpers["new_id"]() + + try: + stmt_group = env.DB.prepare( + "INSERT INTO study_groups" + " (id,name,description,activity_id,creator_id,max_members,is_private)" + " VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), ?, ?, ?)" + ).bind(group_id, name, description, activity_id, user["id"], max_members, 1 if is_private else 0) + + stmt_member = env.DB.prepare( + "INSERT INTO study_group_members (id,group_id,user_id,role) VALUES (?,?,?,?)" + ).bind(member_id, group_id, user["id"], "creator") + + await _run_batch(env, [stmt_group, stmt_member]) + except Exception as exc: + print( + "[study_groups._create_group.bind] " + f"group_id={group_id!r} name={name!r} description={description!r} " + f"activity_id={activity_id!r} user_id={user.get('id')!r} " + f"max_members={max_members!r} is_private={is_private!r} error={exc}" + ) + if "FOREIGN KEY" in str(exc): + return helpers["err"]("Invalid activity_id", 400) + return helpers["err"]("Failed to create study group", 500) + + row = await env.DB.prepare( + "SELECT g.id,g.name,g.description,g.activity_id,g.creator_id,g.max_members," + "g.is_private,g.created_at,g.updated_at," + "(SELECT COUNT(*) FROM study_group_members sm WHERE sm.group_id=g.id) AS member_count" + " FROM study_groups g WHERE g.id=?" + ).bind(group_id).first() + + return helpers["ok"]({ + "group": { + "id": row.id, + "name": row.name, + "description": row.description, + "activity_id": row.activity_id, + "creator_id": row.creator_id, + "max_members": row.max_members, + "is_private": bool(row.is_private), + "member_count": row.member_count, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + }, "Study group created") + + +async def _group_detail(group_id: str, request, env, helpers): + user, bad = await _auth(request, env, helpers) + if bad: + return bad + + group = await env.DB.prepare( + "SELECT g.id,g.name,g.description,g.activity_id,g.creator_id,g.max_members," + "g.is_private,g.created_at,g.updated_at,u.username AS creator_username_enc" + " FROM study_groups g" + " JOIN users u ON u.id=g.creator_id" + " WHERE g.id=?" + ).bind(group_id).first() + + if not group: + return helpers["err"]("Study group not found", 404) + + membership = await env.DB.prepare( + "SELECT role FROM study_group_members WHERE group_id=? AND user_id=?" + ).bind(group_id, user["id"]).first() + + if group.is_private and not membership: + return helpers["err"]("Forbidden", 403) + + rows = await env.DB.prepare( + "SELECT sm.user_id,sm.role,sm.joined_at,u.username AS username_enc" + " FROM study_group_members sm" + " JOIN users u ON u.id=sm.user_id" + " WHERE sm.group_id=?" + " ORDER BY sm.joined_at ASC" + ).bind(group_id).all() + + members = [] + for r in rows.results or []: + members.append({ + "user_id": r.user_id, + "username": await helpers["decrypt_aes"](r.username_enc or "", env.ENCRYPTION_KEY), + "role": r.role, + "joined_at": r.joined_at, + }) + + creator_username = await helpers["decrypt_aes"](group.creator_username_enc or "", env.ENCRYPTION_KEY) + + return helpers["ok"]({ + "group": { + "id": group.id, + "name": group.name, + "description": group.description, + "activity_id": group.activity_id, + "creator_id": group.creator_id, + "creator_username": creator_username, + "max_members": group.max_members, + "is_private": bool(group.is_private), + "created_at": group.created_at, + "updated_at": group.updated_at, + "member_count": len(members), + "members": members, + "requester_role": membership.role if membership else None, + } + }) + + +async def _delete_group(group_id: str, request, env, helpers): + user, bad = await _auth(request, env, helpers) + if bad: + return bad + + row = await env.DB.prepare( + "SELECT creator_id FROM study_groups WHERE id=?" + ).bind(group_id).first() + if not row: + return helpers["err"]("Study group not found", 404) + + if row.creator_id != user["id"]: + return helpers["err"]("Only the group creator can delete this group", 403) + + await env.DB.prepare("DELETE FROM study_groups WHERE id=?").bind(group_id).run() + return helpers["ok"](None, "Study group deleted") + + +async def _join_group(group_id: str, request, env, helpers): + user, bad = await _auth(request, env, helpers) + if bad: + return bad + + group = await env.DB.prepare( + "SELECT id,name,creator_id,max_members,is_private FROM study_groups WHERE id=?" + ).bind(group_id).first() + if not group: + return helpers["err"]("Study group not found", 404) + + if group.is_private: + return helpers["err"]("Private groups require an invitation", 403) + + existing = await env.DB.prepare( + "SELECT id FROM study_group_members WHERE group_id=? AND user_id=?" + ).bind(group_id, user["id"]).first() + if existing: + return helpers["err"]("You are already a member of this group", 409) + + count_row = await env.DB.prepare( + "SELECT COUNT(*) AS cnt FROM study_group_members WHERE group_id=?" + ).bind(group_id).first() + if (count_row.cnt if count_row else 0) >= group.max_members: + return helpers["err"]("This group is full", 400) + + await env.DB.prepare( + "INSERT INTO study_group_members (id,group_id,user_id,role) VALUES (?,?,?,?)" + ).bind(helpers["new_id"](), group_id, user["id"], "member").run() + + if group.creator_id != user["id"]: + await _notify( + helpers, + env, + group.creator_id, + "New Study Group Member", + f"{user.get('username', 'A user')} joined {group.name}", + related_id=group_id, + ) + + return helpers["ok"](None, "Joined study group") + + +async def _leave_group(group_id: str, request, env, helpers): + user, bad = await _auth(request, env, helpers) + if bad: + return bad + + group = await env.DB.prepare( + "SELECT id,creator_id FROM study_groups WHERE id=?" + ).bind(group_id).first() + if not group: + return helpers["err"]("Study group not found", 404) + + membership = await env.DB.prepare( + "SELECT id FROM study_group_members WHERE group_id=? AND user_id=?" + ).bind(group_id, user["id"]).first() + if not membership: + return helpers["err"]("You are not a member of this group", 400) + + if group.creator_id == user["id"]: + return helpers["err"]("Group creator cannot leave the group", 400) + + await env.DB.prepare( + "DELETE FROM study_group_members WHERE group_id=? AND user_id=?" + ).bind(group_id, user["id"]).run() + + return helpers["ok"](None, "Left study group") + + +async def _invite_member(group_id: str, request, env, helpers): + user, bad = await _auth(request, env, helpers) + if bad: + return bad + + group = await env.DB.prepare( + "SELECT id,name FROM study_groups WHERE id=?" + ).bind(group_id).first() + if not group: + return helpers["err"]("Study group not found", 404) + + inviter_membership = await env.DB.prepare( + "SELECT id FROM study_group_members WHERE group_id=? AND user_id=?" + ).bind(group_id, user["id"]).first() + if not inviter_membership: + return helpers["err"]("Only group members can send invitations", 403) + + body, bad_resp = await helpers["parse_json_object"](request) + if bad_resp: + return bad_resp + + invitee_id = (body.get("invitee_id") or "").strip() + identifier = (body.get("username") or body.get("email_or_username") or "").strip() + + invitee = None + if invitee_id: + invitee = await env.DB.prepare("SELECT id, username FROM users WHERE id=?").bind(invitee_id).first() + elif identifier: + invitee = await _lookup_user(env, helpers, identifier) + + if not invitee: + return helpers["err"]("Invitee not found", 404) + + if invitee.id == user["id"]: + return helpers["err"]("You cannot invite yourself", 400) + + already_member = await env.DB.prepare( + "SELECT id FROM study_group_members WHERE group_id=? AND user_id=?" + ).bind(group_id, invitee.id).first() + if already_member: + return helpers["err"]("User is already a member of this group", 400) + + pending = await env.DB.prepare( + "SELECT id FROM study_group_invites" + " WHERE group_id=? AND invitee_id=? AND status='pending'" + ).bind(group_id, invitee.id).first() + if pending: + return helpers["err"]("A pending invite already exists for this user", 400) + + try: + await env.DB.prepare( + "INSERT INTO study_group_invites (id,group_id,inviter_id,invitee_id,status)" + " VALUES (?,?,?,?,?)" + ).bind(helpers["new_id"](), group_id, user["id"], invitee.id, "pending").run() + except Exception as exc: + if "UNIQUE" in str(exc): + return helpers["err"]("A pending invite already exists for this user", 400) + return helpers["err"]("Failed to create invitation", 500) + + await _notify( + helpers, + env, + invitee.id, + "Study Group Invitation", + f"{user.get('username', 'A user')} invited you to join {group.name}", + related_id=group_id, + ) + + return helpers["ok"](None, "Invitation sent") + + +async def _list_invitations(request, env, helpers): + user, bad = await _auth(request, env, helpers) + if bad: + return bad + + rows = await env.DB.prepare( + "SELECT i.id,i.group_id,i.inviter_id,i.status,i.created_at,g.name AS group_name," + "u.username AS inviter_username_enc" + " FROM study_group_invites i" + " JOIN study_groups g ON g.id=i.group_id" + " JOIN users u ON u.id=i.inviter_id" + " WHERE i.invitee_id=? AND i.status='pending'" + " ORDER BY i.created_at DESC" + ).bind(user["id"]).all() + + invitations = [] + for r in rows.results or []: + invitations.append({ + "id": r.id, + "group_id": r.group_id, + "group_name": r.group_name, + "inviter_id": r.inviter_id, + "inviter_username": await helpers["decrypt_aes"](r.inviter_username_enc or "", env.ENCRYPTION_KEY), + "status": r.status, + "created_at": r.created_at, + }) + + return helpers["ok"]({"invitations": invitations}) + + +async def _respond_invitation(invite_id: str, request, env, helpers): + user, bad = await _auth(request, env, helpers) + if bad: + return bad + + body, bad_resp = await helpers["parse_json_object"](request) + if bad_resp: + return bad_resp + + action = (body.get("action") or "").strip().lower() + if action not in ("accept", "decline"): + return helpers["err"]("action must be 'accept' or 'decline'", 400) + + invite = await env.DB.prepare( + "SELECT i.id,i.group_id,i.inviter_id,i.invitee_id,i.status,g.name AS group_name,g.max_members" + " FROM study_group_invites i" + " JOIN study_groups g ON g.id=i.group_id" + " WHERE i.id=?" + ).bind(invite_id).first() + + if not invite: + return helpers["err"]("Invitation not found", 404) + + if invite.invitee_id != user["id"]: + return helpers["err"]("Only the invitee can respond to this invitation", 403) + + if invite.status != "pending": + return helpers["err"]("Invitation has already been responded to", 400) + + if action == "accept": + existing = await env.DB.prepare( + "SELECT id FROM study_group_members WHERE group_id=? AND user_id=?" + ).bind(invite.group_id, user["id"]).first() + if existing: + return helpers["err"]("You are already a member of this group", 400) + + count_row = await env.DB.prepare( + "SELECT COUNT(*) AS cnt FROM study_group_members WHERE group_id=?" + ).bind(invite.group_id).first() + + if (count_row.cnt if count_row else 0) >= invite.max_members: + return helpers["err"]("This group is full", 400) + + stmt_member = env.DB.prepare( + "INSERT INTO study_group_members (id,group_id,user_id,role) VALUES (?,?,?,?)" + ).bind(helpers["new_id"](), invite.group_id, user["id"], "member") + + stmt_update = env.DB.prepare( + "UPDATE study_group_invites SET status='accepted', updated_at=datetime('now') WHERE id=?" + ).bind(invite_id) + + try: + await _run_batch(env, [stmt_member, stmt_update]) + except Exception as exc: + if "UNIQUE" in str(exc): + return helpers["err"]("You are already a member of this group", 400) + return helpers["err"]("Failed to accept invitation", 500) + + await _notify( + helpers, + env, + invite.inviter_id, + "Invitation Accepted", + f"{user.get('username', 'A user')} accepted your invite to {invite.group_name}", + related_id=invite.group_id, + ) + return helpers["ok"](None, "Invitation accepted") + + await env.DB.prepare( + "UPDATE study_group_invites SET status='declined', updated_at=datetime('now') WHERE id=?" + ).bind(invite_id).run() + + await _notify( + helpers, + env, + invite.inviter_id, + "Invitation Declined", + f"{user.get('username', 'A user')} declined your invite to {invite.group_name}", + related_id=invite.group_id, + ) + + return helpers["ok"](None, "Invitation declined") + + +async def _activity_groups(activity_id: str, request, env, helpers): + user, bad = await _auth(request, env, helpers) + if bad: + return bad + + _ = user + rows = await env.DB.prepare( + "SELECT g.id,g.name,g.description,g.activity_id,g.creator_id,g.max_members," + "g.is_private,g.created_at,g.updated_at,COUNT(m.id) AS member_count" + " FROM study_groups g" + " LEFT JOIN study_group_members m ON m.group_id=g.id" + " WHERE g.activity_id=? AND g.is_private=0" + " GROUP BY g.id" + " ORDER BY g.created_at DESC" + ).bind(activity_id).all() + + groups = [] + for r in rows.results or []: + groups.append({ + "id": r.id, + "name": r.name, + "description": r.description, + "activity_id": r.activity_id, + "creator_id": r.creator_id, + "max_members": r.max_members, + "is_private": bool(r.is_private), + "member_count": r.member_count, + "created_at": r.created_at, + "updated_at": r.updated_at, + }) + + return helpers["ok"]({"groups": groups}) + + +async def handle(request, env, path: str, method: str, helpers): + if path == "/api/study-groups" and method == "GET": + return await _list_groups(request, env, helpers) + + if path == "/api/study-groups" and method == "POST": + return await _create_group(request, env, helpers) + + m_detail = re.fullmatch(r"/api/study-groups/([A-Za-z0-9_-]+)", path) + if m_detail and method == "GET": + return await _group_detail(m_detail.group(1), request, env, helpers) + if m_detail and method == "DELETE": + return await _delete_group(m_detail.group(1), request, env, helpers) + + m_join = re.fullmatch(r"/api/study-groups/([A-Za-z0-9_-]+)/join", path) + if m_join and method == "POST": + return await _join_group(m_join.group(1), request, env, helpers) + + m_leave = re.fullmatch(r"/api/study-groups/([A-Za-z0-9_-]+)/leave", path) + if m_leave and method == "DELETE": + return await _leave_group(m_leave.group(1), request, env, helpers) + + m_invite = re.fullmatch(r"/api/study-groups/([A-Za-z0-9_-]+)/invite", path) + if m_invite and method == "POST": + return await _invite_member(m_invite.group(1), request, env, helpers) + + if path == "/api/invitations" and method == "GET": + return await _list_invitations(request, env, helpers) + + m_respond = re.fullmatch(r"/api/invitations/([A-Za-z0-9_-]+)/respond", path) + if m_respond and method == "POST": + return await _respond_invitation(m_respond.group(1), request, env, helpers) + + m_activity = re.fullmatch(r"/api/activities/([A-Za-z0-9_-]+)/groups", path) + if m_activity and method == "GET": + return await _activity_groups(m_activity.group(1), request, env, helpers) + + return helpers["err"]("API endpoint not found", 404) diff --git a/src/worker.py b/src/worker.py index f274a07..5a4424d 100644 --- a/src/worker.py +++ b/src/worker.py @@ -36,9 +36,11 @@ import datetime import hashlib import hmac as _hmac +import importlib import json import os import re +import sys import traceback from types import SimpleNamespace from typing import Any, Dict, Optional @@ -676,6 +678,49 @@ async def send_password_reset_email(to_email: str, _username: str, token: str, e "CREATE INDEX IF NOT EXISTS idx_sa_session ON session_attendance(session_id)", "CREATE INDEX IF NOT EXISTS idx_sa_user ON session_attendance(user_id)", "CREATE INDEX IF NOT EXISTS idx_at_activity ON activity_tags(activity_id)", + # Study groups + """CREATE TABLE IF NOT EXISTS study_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + activity_id TEXT, + creator_id TEXT NOT NULL, + max_members INTEGER NOT NULL DEFAULT 10, + is_private INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE SET NULL, + FOREIGN KEY (creator_id) REFERENCES users(id) ON DELETE CASCADE + )""", + """CREATE TABLE IF NOT EXISTS study_group_members ( + id TEXT PRIMARY KEY, + group_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + joined_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (group_id, user_id), + FOREIGN KEY (group_id) REFERENCES study_groups(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + )""", + """CREATE TABLE IF NOT EXISTS study_group_invites ( + id TEXT PRIMARY KEY, + group_id TEXT NOT NULL, + inviter_id TEXT NOT NULL, + invitee_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 (group_id, invitee_id), + FOREIGN KEY (group_id) REFERENCES study_groups(id) ON DELETE CASCADE, + FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (invitee_id) REFERENCES users(id) ON DELETE CASCADE + )""", + "CREATE INDEX IF NOT EXISTS idx_sg_activity ON study_groups(activity_id)", + "CREATE INDEX IF NOT EXISTS idx_sg_creator ON study_groups(creator_id)", + "CREATE INDEX IF NOT EXISTS idx_sgm_group ON study_group_members(group_id)", + "CREATE INDEX IF NOT EXISTS idx_sgm_user ON study_group_members(user_id)", + "CREATE INDEX IF NOT EXISTS idx_sgi_group ON study_group_invites(group_id)", + "CREATE INDEX IF NOT EXISTS idx_sgi_invitee_status ON study_group_invites(invitee_id, status)", # Notifications """CREATE TABLE IF NOT EXISTS notifications ( id TEXT PRIMARY KEY, @@ -2624,6 +2669,29 @@ async def _dispatch(request, env): return err("Failed to connect to presence channel", 500) if path.startswith("/api/"): + if ( + path.startswith("/api/study-groups") + or path.startswith("/api/invitations") + or re.fullmatch(r"/api/activities/[A-Za-z0-9_-]+/groups", path) + ): + study_groups_api = _get_study_groups_module() + return await study_groups_api.handle( + request, + env, + path, + method, + { + "verify_token": verify_token, + "parse_json_object": parse_json_object, + "ok": ok, + "err": err, + "new_id": new_id, + "_create_notification": _create_notification, + "blind_index": blind_index, + "decrypt_aes": decrypt_aes, + }, + ) + if path == "/api/init" and method == "POST": try: await init_db(env) @@ -2722,6 +2790,27 @@ async def on_fetch(request, env): return err("Internal server error", 500) +def _get_study_groups_module(): + cached = sys.modules.get("study_groups") or sys.modules.get("src.study_groups") + if cached is not None: + return cached + + try: + mod = importlib.import_module("study_groups") + except Exception: + try: + mod = importlib.import_module("src.study_groups") + except Exception: + spec = importlib.util.spec_from_file_location( + "study_groups", os.path.join(os.path.dirname(__file__), "study_groups.py") + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + sys.modules.setdefault("study_groups", mod) + return mod + + # --------------------------------------------------------------------------- # Notifications API # --------------------------------------------------------------------------- diff --git a/tests/test_study_groups_api.py b/tests/test_study_groups_api.py new file mode 100644 index 0000000..03af3ac --- /dev/null +++ b/tests/test_study_groups_api.py @@ -0,0 +1,286 @@ +import json +from unittest.mock import AsyncMock + +from tests.helpers import load_worker, MockRequest, MockRow, MockDB, make_env, make_stmt, json_request + + +worker = load_worker() +JWT = "test-jwt-secret" + + +def _parse(resp): + return json.loads(resp.body) + + +def _auth_header(uid="u1", username="alice", role="member"): + token = worker.create_token(uid, username, role, JWT) + return {"Authorization": f"Bearer {token}"} + + +class BatchMockDB(MockDB): + def __init__(self, stmts=None): + super().__init__(stmts=stmts) + self.batch = AsyncMock(return_value=None) + + +class TestStudyGroupsApi: + async def test_auth_required_all_endpoints(self): + requests = [ + MockRequest(method="GET", url="http://localhost/api/study-groups"), + json_request("/api/study-groups", {"name": "x"}), + MockRequest(method="GET", url="http://localhost/api/study-groups/g1"), + MockRequest(method="DELETE", url="http://localhost/api/study-groups/g1"), + MockRequest(method="POST", url="http://localhost/api/study-groups/g1/join"), + MockRequest(method="DELETE", url="http://localhost/api/study-groups/g1/leave"), + json_request("/api/study-groups/g1/invite", {"username": "bob"}), + MockRequest(method="GET", url="http://localhost/api/invitations"), + json_request("/api/invitations/i1/respond", {"action": "accept"}), + MockRequest(method="GET", url="http://localhost/api/activities/a1/groups"), + ] + + env = make_env(db=MockDB(), jwt_secret=JWT) + for req in requests: + resp = await worker._dispatch(req, env) + assert resp.status == 401 + + async def test_create_group_inserts_group_and_creator_membership(self): + db = BatchMockDB([ + make_stmt(), + make_stmt(), + make_stmt(first=MockRow( + id="g1", name="Math", description="desc", activity_id=None, + creator_id="u1", max_members=10, is_private=0, + created_at="2026-01-01", updated_at="2026-01-01", member_count=1, + )), + ]) + env = make_env(db=db, jwt_secret=JWT) + req = json_request( + "/api/study-groups", + {"name": "Math", "description": "desc", "max_members": 10, "is_private": False}, + headers=_auth_header(uid="u1", username="alice"), + ) + + resp = await worker._dispatch(req, env) + body = _parse(resp) + + assert resp.status == 200 + assert body["success"] is True + assert db.batch.await_count == 1 + assert body["data"]["group"]["id"] == "g1" + + async def test_list_groups_public_and_private_visibility(self): + rows = [ + MockRow( + id="g1", name="Public", description="d", activity_id=None, + creator_id="u2", max_members=10, is_private=0, created_at="x", updated_at="x", + member_count=2, is_member=0, is_creator=0, + ) + ] + env = make_env(db=MockDB([make_stmt(all_results=rows)]), jwt_secret=JWT) + req = MockRequest( + method="GET", + url="http://localhost/api/study-groups", + headers=_auth_header(uid="u1", username="alice"), + ) + resp = await worker._dispatch(req, env) + body = _parse(resp) + + assert resp.status == 200 + assert len(body["data"]["groups"]) == 1 + assert body["data"]["groups"][0]["name"] == "Public" + + async def test_private_group_detail_forbidden_for_non_member(self): + db = MockDB([ + make_stmt(first=MockRow( + id="g1", name="Private", description="d", activity_id=None, + creator_id="u2", max_members=10, is_private=1, + created_at="x", updated_at="x", creator_username_enc="", + )), + make_stmt(first=None), + ]) + env = make_env(db=db, jwt_secret=JWT) + + req = MockRequest( + method="GET", + url="http://localhost/api/study-groups/g1", + headers=_auth_header(uid="u1", username="alice"), + ) + resp = await worker._dispatch(req, env) + assert resp.status == 403 + + async def test_delete_group_non_creator_forbidden(self): + db = MockDB([make_stmt(first=MockRow(creator_id="u2"))]) + env = make_env(db=db, jwt_secret=JWT) + + req = MockRequest( + method="DELETE", + url="http://localhost/api/study-groups/g1", + headers=_auth_header(uid="u1", username="alice"), + ) + resp = await worker._dispatch(req, env) + assert resp.status == 403 + + async def test_join_public_group_success_and_join_twice_conflict(self): + db1 = MockDB([ + make_stmt(first=MockRow(id="g1", name="G", creator_id="u2", max_members=10, is_private=0)), + make_stmt(first=None), + make_stmt(first=MockRow(cnt=1)), + make_stmt(), + ]) + env1 = make_env(db=db1, jwt_secret=JWT) + req = MockRequest(method="POST", url="http://localhost/api/study-groups/g1/join", headers=_auth_header(uid="u1", username="alice")) + resp = await worker._dispatch(req, env1) + assert resp.status == 200 + + db2 = MockDB([ + make_stmt(first=MockRow(id="g1", name="G", creator_id="u2", max_members=10, is_private=0)), + make_stmt(first=MockRow(id="m1")), + ]) + env2 = make_env(db=db2, jwt_secret=JWT) + resp2 = await worker._dispatch(req, env2) + assert resp2.status in (400, 409) + + async def test_join_private_group_forbidden(self): + db = MockDB([make_stmt(first=MockRow(id="g1", name="G", creator_id="u2", max_members=10, is_private=1))]) + env = make_env(db=db, jwt_secret=JWT) + req = MockRequest(method="POST", url="http://localhost/api/study-groups/g1/join", headers=_auth_header(uid="u1", username="alice")) + resp = await worker._dispatch(req, env) + assert resp.status == 403 + + async def test_join_group_at_capacity_returns_400(self): + db = MockDB([ + make_stmt(first=MockRow(id="g1", name="G", creator_id="u2", max_members=2, is_private=0)), + make_stmt(first=None), + make_stmt(first=MockRow(cnt=2)), + ]) + env = make_env(db=db, jwt_secret=JWT) + req = MockRequest(method="POST", url="http://localhost/api/study-groups/g1/join", headers=_auth_header(uid="u1", username="alice")) + resp = await worker._dispatch(req, env) + assert resp.status == 400 + + async def test_leave_as_non_creator_succeeds_and_group_exists(self): + db = MockDB([ + make_stmt(first=MockRow(id="g1", creator_id="u2")), + make_stmt(first=MockRow(id="m1")), + make_stmt(), + ]) + env = make_env(db=db, jwt_secret=JWT) + req = MockRequest(method="DELETE", url="http://localhost/api/study-groups/g1/leave", headers=_auth_header(uid="u1", username="alice")) + resp = await worker._dispatch(req, env) + assert resp.status == 200 + + async def test_leave_as_creator_returns_400(self): + db = MockDB([ + make_stmt(first=MockRow(id="g1", creator_id="u1")), + make_stmt(first=MockRow(id="m1")), + ]) + env = make_env(db=db, jwt_secret=JWT) + req = MockRequest(method="DELETE", url="http://localhost/api/study-groups/g1/leave", headers=_auth_header(uid="u1", username="alice")) + resp = await worker._dispatch(req, env) + body = _parse(resp) + assert resp.status == 400 + assert body["error"] == "Group creator cannot leave the group" + + async def test_invite_by_non_member_returns_403(self): + db = MockDB([ + make_stmt(first=MockRow(id="g1", name="G")), + make_stmt(first=None), + ]) + env = make_env(db=db, jwt_secret=JWT) + req = json_request( + "/api/study-groups/g1/invite", + {"username": "bob"}, + headers=_auth_header(uid="u1", username="alice"), + ) + resp = await worker._dispatch(req, env) + assert resp.status == 403 + + async def test_duplicate_invite_returns_400(self): + db = MockDB([ + make_stmt(first=MockRow(id="g1", name="G")), + make_stmt(first=MockRow(id="m1")), + make_stmt(first=MockRow(id="u2", username="enc")), + make_stmt(first=None), + make_stmt(first=MockRow(id="i1")), + ]) + env = make_env(db=db, jwt_secret=JWT) + req = json_request( + "/api/study-groups/g1/invite", + {"username": "bob"}, + headers=_auth_header(uid="u1", username="alice"), + ) + resp = await worker._dispatch(req, env) + assert resp.status == 400 + + async def test_invite_full_group_accept_fails_at_respond(self): + db = MockDB([ + make_stmt(first=MockRow( + id="i1", group_id="g1", inviter_id="u2", invitee_id="u1", + status="pending", group_name="G", max_members=2, + )), + make_stmt(first=None), + make_stmt(first=MockRow(cnt=2)), + ]) + env = make_env(db=db, jwt_secret=JWT) + req = json_request( + "/api/invitations/i1/respond", + {"action": "accept"}, + headers=_auth_header(uid="u1", username="alice"), + ) + resp = await worker._dispatch(req, env) + assert resp.status == 400 + + async def test_respond_accept_inserts_member_and_updates_invite_atomically(self): + db = BatchMockDB([ + make_stmt(first=MockRow( + id="i1", group_id="g1", inviter_id="u2", invitee_id="u1", + status="pending", group_name="G", max_members=5, + )), + make_stmt(first=None), + make_stmt(first=MockRow(cnt=1)), + make_stmt(), + make_stmt(), + ]) + env = make_env(db=db, jwt_secret=JWT) + req = json_request( + "/api/invitations/i1/respond", + {"action": "accept"}, + headers=_auth_header(uid="u1", username="alice"), + ) + resp = await worker._dispatch(req, env) + assert resp.status == 200 + assert db.batch.await_count == 1 + + async def test_respond_decline_updates_status_without_member_insert(self): + db = BatchMockDB([ + make_stmt(first=MockRow( + id="i1", group_id="g1", inviter_id="u2", invitee_id="u1", + status="pending", group_name="G", max_members=5, + )), + make_stmt(), + ]) + env = make_env(db=db, jwt_secret=JWT) + req = json_request( + "/api/invitations/i1/respond", + {"action": "decline"}, + headers=_auth_header(uid="u1", username="alice"), + ) + resp = await worker._dispatch(req, env) + assert resp.status == 200 + assert db.batch.await_count == 0 + + async def test_only_invitee_can_respond(self): + db = MockDB([ + make_stmt(first=MockRow( + id="i1", group_id="g1", inviter_id="u2", invitee_id="u3", + status="pending", group_name="G", max_members=5, + )), + ]) + env = make_env(db=db, jwt_secret=JWT) + req = json_request( + "/api/invitations/i1/respond", + {"action": "accept"}, + headers=_auth_header(uid="u1", username="alice"), + ) + resp = await worker._dispatch(req, env) + assert resp.status == 403 From fc37239823306915f74e3ef896b0e87c07c45099 Mon Sep 17 00:00:00 2001 From: Ananya Date: Sun, 5 Jul 2026 20:04:51 +0530 Subject: [PATCH 2/2] study groups fixes --- public/invitations.html | 4 +-- public/study-group-detail.html | 3 ++- public/study-groups.html | 45 ++++++++++++++++++++++++++++------ src/study_groups.py | 10 +++++++- src/worker.py | 1 + tests/test_study_groups_api.py | 2 +- 6 files changed, 51 insertions(+), 14 deletions(-) diff --git a/public/invitations.html b/public/invitations.html index a8e5608..87cd1ce 100644 --- a/public/invitations.html +++ b/public/invitations.html @@ -20,7 +20,7 @@

Pending Invitations

-
+
@@ -98,8 +98,6 @@

${esc(i.group_name)}

await loadInvites(); return; } - article.classList.add('opacity-50'); - setTimeout(() => article.remove(), 250); await loadInvites(); } diff --git a/public/study-group-detail.html b/public/study-group-detail.html index c649e51..2e745ba 100644 --- a/public/study-group-detail.html +++ b/public/study-group-detail.html @@ -30,8 +30,9 @@

diff --git a/public/study-groups.html b/public/study-groups.html index c7aa373..48fbf09 100644 --- a/public/study-groups.html +++ b/public/study-groups.html @@ -64,11 +64,19 @@

Create New Group

- - + +
+

+

Public Groups

@@ -109,6 +117,21 @@

My Groups

return res; } + async function readErrorMessage(res) { + try { + const body = await res.json(); + return body.error || body.message || 'Request failed'; + } catch (_) { + return 'Request failed'; + } + } + + function setActionMsg(message, isError = false) { + const el = document.getElementById('action-msg'); + el.className = `mb-4 text-sm ${isError ? 'text-red-600' : 'text-green-600'}`; + el.textContent = message; + } + function card(g) { const activityBadge = g.activity_id ? `Activity: ${esc(g.activity_id)}` @@ -198,16 +221,12 @@

${esc(g.name)}

is_private: !!fd.get('is_private'), }; const res = await apiFetch('/api/study-groups', { method: 'POST', body: JSON.stringify(payload) }); - const msg = document.getElementById('create-msg'); if (!res) return; - const body = await res.json(); if (!res.ok) { - msg.className = 'ml-3 text-sm text-red-600'; - msg.textContent = body.error || 'Failed to create group'; + setActionMsg(await readErrorMessage(res), true); return; } - msg.className = 'ml-3 text-sm text-green-600'; - msg.textContent = 'Group created'; + setActionMsg('Group created'); e.target.reset(); await loadGroups(); await loadInvitesCount(); @@ -219,6 +238,11 @@

${esc(g.name)}

const id = btn.getAttribute('data-join'); const res = await apiFetch(`/api/study-groups/${encodeURIComponent(id)}/join`, { method: 'POST' }); if (!res) return; + if (!res.ok) { + setActionMsg(await readErrorMessage(res), true); + return; + } + setActionMsg('Joined group'); await loadGroups(); }); @@ -228,6 +252,11 @@

${esc(g.name)}

const id = btn.getAttribute('data-leave'); const res = await apiFetch(`/api/study-groups/${encodeURIComponent(id)}/leave`, { method: 'DELETE' }); if (!res) return; + if (!res.ok) { + setActionMsg(await readErrorMessage(res), true); + return; + } + setActionMsg('Left group'); await loadGroups(); }); diff --git a/src/study_groups.py b/src/study_groups.py index 1d4f0d0..6c08741 100644 --- a/src/study_groups.py +++ b/src/study_groups.py @@ -405,7 +405,15 @@ async def _invite_member(group_id: str, request, env, helpers): ).bind(helpers["new_id"](), group_id, user["id"], invitee.id, "pending").run() except Exception as exc: if "UNIQUE" in str(exc): - return helpers["err"]("A pending invite already exists for this user", 400) + try: + await env.DB.prepare( + "UPDATE study_group_invites" + " SET inviter_id=?, status='pending', updated_at=datetime('now')" + " WHERE group_id=? AND invitee_id=?" + ).bind(user["id"], group_id, invitee.id).run() + except Exception: + return helpers["err"]("Failed to create invitation", 500) + return helpers["ok"](None, "Invitation sent") return helpers["err"]("Failed to create invitation", 500) await _notify( diff --git a/src/worker.py b/src/worker.py index 5a4424d..fff117f 100644 --- a/src/worker.py +++ b/src/worker.py @@ -34,6 +34,7 @@ import base64 import datetime +import importlib.util import hashlib import hmac as _hmac import importlib diff --git a/tests/test_study_groups_api.py b/tests/test_study_groups_api.py index 03af3ac..8b49be8 100644 --- a/tests/test_study_groups_api.py +++ b/tests/test_study_groups_api.py @@ -138,7 +138,7 @@ async def test_join_public_group_success_and_join_twice_conflict(self): ]) env2 = make_env(db=db2, jwt_secret=JWT) resp2 = await worker._dispatch(req, env2) - assert resp2.status in (400, 409) + assert resp2.status == 409 async def test_join_private_group_forbidden(self): db = MockDB([make_stmt(first=MockRow(id="g1", name="G", creator_id="u2", max_members=10, is_private=1))])