diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-28 10:09:38 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-28 10:09:38 +0200 |
| commit | 58ca879b017da62e77f40752d8d94fef3f315a1e (patch) | |
| tree | 38cb9b1f8007009e39b462bcda16830aa2b0ede3 /packages/meshbay-hub/src/meshbay_hub/api | |
| parent | 471fa6242fc56a035a8c9639722a82b9341328c2 (diff) | |
| download | meshbay-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')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/admin.py | 6 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/groups.py | 62 |
2 files changed, 55 insertions, 13 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py index 4141c79..ab6fa07 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py @@ -301,12 +301,18 @@ async def admin_list_groups( total = (await db.execute(select(func.count()).select_from(Group))).scalar_one() + owner_ids = {g.admin_id for g, _ in rows} + owner_by_id = dict((await db.execute( + select(User.id, User.username).where(User.id.in_(owner_ids)))).all()) \ + if owner_ids else {} + return { "groups": [ { "id": g.id, "name": g.name, "admin_id": g.admin_id, + "owner_username": owner_by_id.get(g.admin_id), "visibility": g.visibility, "description": g.description or "", "status": g.status, 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}") |