aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/db
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/db
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/db')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c3d4e5f6a7b8_group_name_unique_per_owner.py45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py11
2 files changed, 54 insertions, 2 deletions
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):