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..87cd1ce
--- /dev/null
+++ b/public/invitations.html
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+ 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..2e745ba
--- /dev/null
+++ b/public/study-group-detail.html
@@ -0,0 +1,180 @@
+
+
+
+
+
+
+ Study Group Detail - Alpha One Labs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Invite user
+
+ Username or email
+
+ Invite
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/study-groups.html b/public/study-groups.html
new file mode 100644
index 0000000..48fbf09
--- /dev/null
+++ b/public/study-groups.html
@@ -0,0 +1,271 @@
+
+
+
+
+
+
+ Study Groups - Alpha One Labs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..6c08741
--- /dev/null
+++ b/src/study_groups.py
@@ -0,0 +1,615 @@
+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):
+ 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(
+ 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..fff117f 100644
--- a/src/worker.py
+++ b/src/worker.py
@@ -34,11 +34,14 @@
import base64
import datetime
+import importlib.util
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 +679,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 +2670,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 +2791,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..8b49be8
--- /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 == 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