aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/groups.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-28 10:09:38 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-28 10:09:38 +0200
commit58ca879b017da62e77f40752d8d94fef3f315a1e (patch)
tree38cb9b1f8007009e39b462bcda16830aa2b0ede3 /packages/meshbay-hub/src/meshbay_hub/api/groups.py
parent471fa6242fc56a035a8c9639722a82b9341328c2 (diff)
downloadmeshbay-58ca879b017da62e77f40752d8d94fef3f315a1e.tar.gz
feat(hub): group names unique per owner, shown as name@owner
A group's identity stays its UUID. What changes is that "the name is unique" — until now an unenforced expectation — becomes real, scoped to the owner account, and the owner's username is surfaced so two groups called "photos" on different nodes can be told apart. Hub: - `groups` gains a functional unique index `uq_groups_owner_name` on `(admin_id, lower(name))` (model + migration c3d4e5f6a7b8). The migration pre-flights: if the data already clashes it aborts and lists the offending (admin_id, name) pairs rather than renaming anyone's group. meshbay.org checked clean. - `create_group` trims the name, rejects blank (422) and an owner-scoped case-insensitive clash (409), with an IntegrityError backstop for the race, and returns `owner_username`. - `owner_username` added to `/v1/groups/mine`, `GET /v1/groups` (local rows), `POST /v1/groups/{id}/join`, `GET /v1/admin/groups`. SPA: - new `static/group-name.js` — `<GroupName name owner [inline]>` renders the name with the `@owner` handle on a smaller grey line under it. - used in the sidebar, the group-page header, Explore cards, the Admin groups table, and cross-group Search (via a widened `cacheGroupIndex` carrying the owner). Delete/leave confirmations show `name@owner` inline. - federated Explore rows show `@<source_hub>` instead of an account. Design record and the locked decisions: ~/next/groupnames.md (out of repo). MNP unchanged. Tests: test_group_name_unique.py, test_group_name_migration.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/groups.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py62
1 files changed, 49 insertions, 13 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index 91aa9c4..49902de 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from datetime import datetime, timezone
from sqlalchemy import func, or_, select, update
+from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub import hub_settings
@@ -31,8 +32,9 @@ async def my_groups(
from meshbay_hub.api.revocation import get_online_nodes_for_group
result = await db.execute(
- select(Group)
+ select(Group, User.username)
.join(GroupMember, Group.id == GroupMember.group_id)
+ .join(User, User.id == Group.admin_id) # the owner, for the @handle
.where(GroupMember.user_id == current_user.id, Group.status == "active",
# A group nobody hosts yet is the owner's business alone. Someone
# added to it before a node exists would see a name they cannot
@@ -40,7 +42,9 @@ async def my_groups(
or_(Group.hosted_at.is_not(None), Group.admin_id == current_user.id))
.order_by(Group.last_activity_at.desc())
)
- groups = result.scalars().all()
+ rows = result.all()
+ groups = [g for g, _ in rows]
+ owner_by_id = {g.id: owner for g, owner in rows}
muted_rows = await db.execute(
select(GroupMember.group_id, GroupMember.muted)
.where(GroupMember.user_id == current_user.id))
@@ -50,6 +54,7 @@ async def my_groups(
{
"id": g.id,
"name": g.name,
+ "owner_username": owner_by_id.get(g.id),
"visibility": g.visibility,
"muted": muted_map.get(g.id, False),
"join_policy": g.join_policy,
@@ -147,22 +152,24 @@ async def list_public_groups(
# Unhosted groups are absent from the directory: until a node announces it,
# a group has no files, no key and nothing to connect to, so listing it only
# produces a dead end. Its owner still sees it in /mine while they set it up.
- query = select(Group).where(Group.visibility == "public",
- Group.status == "active",
- Group.hosted_at.is_not(None))
+ query = (select(Group, User.username)
+ .join(User, User.id == Group.admin_id)
+ .where(Group.visibility == "public",
+ Group.status == "active",
+ Group.hosted_at.is_not(None)))
if q:
query = query.where(Group.name.ilike(f"%{q}%"))
result = await db.execute(
query.order_by(Group.created_at.desc()).limit(limit).offset(offset)
)
- local = result.scalars().all()
+ local = result.all()
groups = [
{
"id": g.id, "name": g.name, "join_policy": g.join_policy,
- "description": g.description or "",
+ "owner_username": owner, "description": g.description or "",
"created_at": g.created_at.isoformat(), "source": "local",
}
- for g in local
+ for g, owner in local
]
if include_federated:
@@ -306,7 +313,9 @@ async def join_group(
db.add(IPLog(user_id=current_user.id, event="group_join",
ip_address=client_ip(request), detail=group.name))
await db.commit()
- return {"status": "joined", "group_id": group_id, "name": group.name}
+ owner = await db.scalar(select(User.username).where(User.id == group.admin_id))
+ return {"status": "joined", "group_id": group_id, "name": group.name,
+ "owner_username": owner}
class GroupCreateRequest(BaseModel):
@@ -386,6 +395,25 @@ async def create_group(
status_code=422,
detail="A private group is invite-only. Make it public if you want "
"anyone to be able to join.")
+
+ name = body.name.strip()
+ if not name:
+ raise HTTPException(status_code=422, detail="A group needs a name.")
+ # One name per owner, case-insensitively. Two *different* owners may each
+ # have a "photos" — that is why the check is scoped to `admin_id` and why
+ # the group's real identity stays its UUID. The DB has a unique index too
+ # (uq_groups_owner_name); this is the friendly error, that is the race
+ # backstop below.
+ clash = await db.scalar(
+ select(Group.id).where(
+ Group.admin_id == current_user.id,
+ func.lower(Group.name) == name.lower()))
+ if clash:
+ raise HTTPException(
+ status_code=409,
+ detail=f"You already have a group called “{name}”. Pick another name "
+ "— names only have to be unique among your own groups.")
+
if body.visibility == "public":
if not await hub_settings.public_groups_allowed(db):
# Instance policy, set by a hub admin in the panel. Absolute — staff
@@ -399,7 +427,7 @@ async def create_group(
desc = (body.description or "")[:512] if body.description else None
group = Group(
- name=body.name,
+ name=name,
admin_id=current_user.id,
visibility=body.visibility,
join_policy=body.join_policy,
@@ -410,10 +438,18 @@ async def create_group(
db.add(GroupMember(group_id=group.id, user_id=current_user.id))
db.add(IPLog(user_id=current_user.id, event="group_create",
- ip_address=client_ip(request), detail=body.name))
- await db.commit()
+ ip_address=client_ip(request), detail=name))
+ try:
+ await db.commit()
+ except IntegrityError:
+ # Two creates for the same owner+name raced past the check above.
+ await db.rollback()
+ raise HTTPException(
+ status_code=409,
+ detail=f"You already have a group called “{name}”.")
await db.refresh(group)
- return {"group_id": group.id, "name": group.name}
+ return {"group_id": group.id, "name": group.name,
+ "owner_username": current_user.username}
@router.delete("/{group_id}/members/{username}")