From 58ca879b017da62e77f40752d8d94fef3f315a1e Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 28 Aug 2026 10:09:38 +0200 Subject: feat(hub): group names unique per owner, shown as name@owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` — `` 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 `@` 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 Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi --- .../c3d4e5f6a7b8_group_name_unique_per_owner.py | 45 ++++++++++++++++++++++ packages/meshbay-hub/src/meshbay_hub/db/models.py | 11 +++++- 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c3d4e5f6a7b8_group_name_unique_per_owner.py (limited to 'packages/meshbay-hub/src/meshbay_hub/db') diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c3d4e5f6a7b8_group_name_unique_per_owner.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c3d4e5f6a7b8_group_name_unique_per_owner.py new file mode 100644 index 0000000..6fc5b73 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c3d4e5f6a7b8_group_name_unique_per_owner.py @@ -0,0 +1,45 @@ +"""group_name_unique_per_owner + +Revision ID: c3d4e5f6a7b8 +Revises: b1c2d3e4f5a6 +Create Date: 2026-08-28 14:00:00.000000 + +One group name per owner, case-insensitively. The group's identity is still +its UUID — this only makes `name@owner` a dependable handle. + +Pre-flight: abort if the data already violates it, with the offending +(admin_id, name) pairs listed, rather than silently renaming anyone's group. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'c3d4e5f6a7b8' +down_revision: Union[str, Sequence[str], None] = 'b1c2d3e4f5a6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + dupes = bind.execute(sa.text( + "SELECT admin_id, lower(name) AS n, count(*) AS c " + "FROM groups GROUP BY admin_id, lower(name) HAVING count(*) > 1" + )).fetchall() + if dupes: + listed = ", ".join(f"{r.admin_id[:8]}/{r.n!r}×{r.c}" for r in dupes) + raise RuntimeError( + "Cannot add uq_groups_owner_name: an owner already has two groups " + f"with the same name (case-insensitively): {listed}. " + "Rename or delete one of each pair, then re-run the migration." + ) + op.create_index( + "uq_groups_owner_name", "groups", + ["admin_id", sa.text("lower(name)")], unique=True, + ) + + +def downgrade() -> None: + op.drop_index("uq_groups_owner_name", table_name="groups") diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index 0c337c1..dbabfc2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -15,7 +15,7 @@ from datetime import datetime, timezone from sqlalchemy import ( Boolean, DateTime, ForeignKey, Index, Integer, - String, Text, UniqueConstraint, + String, Text, UniqueConstraint, text, ) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -109,7 +109,14 @@ class Group(Base): members: Mapped[list["GroupMember"]] = relationship(back_populates="group") - __table_args__ = (Index("ix_groups_name", "name"),) + __table_args__ = ( + Index("ix_groups_name", "name"), + # One name per owner, case-insensitively. The group's identity stays its + # UUID; this is what makes `name@owner` a handle a human can rely on + # (two different owners may still both have a "photos"). Enforced in the + # DB so a race cannot slip a second one past the check in create_group. + Index("uq_groups_owner_name", "admin_id", text("lower(name)"), unique=True), + ) class GroupMember(Base): -- cgit v1.2.3