feat(tenancy): add merchant team CRUD with multi-store hub view

The merchant team page was read-only. Now merchant owners can invite,
edit roles, and remove team members across all their stores from a
single hub view.

Architecture: No new models — delegates to existing store_team_service.
Members are deduplicated across stores with per-store role badges.

New:
- 5 API endpoints: GET team (member-centric), GET store roles, POST
  invite (multi-store), PUT update role, DELETE remove member
- merchant-team.js Alpine component with invite/edit/remove modals
- Full CRUD template with stats cards, store filter, member table
- 7 Pydantic schemas for merchant team request/response
- 2 service methods: validate_store_ownership, get_merchant_team_members
- 25 new i18n keys across 4 tenancy locales + 1 core common key

Tests: 434 tenancy tests passing, arch-check green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 18:57:45 +01:00
parent aaed1b2d01
commit 0455e63a2e
14 changed files with 1131 additions and 158 deletions

View File

@@ -431,6 +431,119 @@ class MerchantStoreService:
}
def get_user(self, db: Session, user_id: int):
"""Get a User ORM object by ID."""
from app.modules.tenancy.models import User
return db.query(User).filter(User.id == user_id).first()
def validate_store_ownership(
self, db: Session, merchant_id: int, store_id: int
) -> Store:
"""
Validate that a store belongs to the merchant.
Returns the Store object if valid, raises exception otherwise.
"""
store = (
db.query(Store)
.filter(Store.id == store_id, Store.merchant_id == merchant_id)
.first()
)
if not store:
from app.modules.tenancy.exceptions import StoreNotFoundException
raise StoreNotFoundException(store_id, identifier_type="id")
return store
def get_merchant_team_members(self, db: Session, merchant_id: int) -> dict:
"""
Get team members across all merchant stores in a member-centric view.
Deduplicates users across stores and aggregates per-store role info.
"""
from app.modules.tenancy.models.store import StoreUser
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
if not merchant:
raise MerchantNotFoundException(merchant_id)
stores = (
db.query(Store)
.filter(Store.merchant_id == merchant_id)
.order_by(Store.name)
.all()
)
# Build member-centric view: keyed by user_id
members_map: dict[int, dict] = {}
store_list = []
for store in stores:
store_list.append({
"id": store.id,
"name": store.name,
"code": store.store_code,
})
store_users = (
db.query(StoreUser)
.filter(StoreUser.store_id == store.id)
.all()
)
for su in store_users:
user = su.user
if not user:
continue
uid = user.id
is_pending = su.invitation_accepted_at is None and su.invitation_token is not None
if uid not in members_map:
members_map[uid] = {
"user_id": uid,
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"full_name": f"{user.first_name or ''} {user.last_name or ''}".strip() or user.email,
"stores": [],
"is_owner": uid == merchant.owner_user_id,
}
members_map[uid]["stores"].append({
"store_id": store.id,
"store_name": store.name,
"store_code": store.store_code,
"role_name": su.role.name if su.role else None,
"role_id": su.role_id,
"is_active": su.is_active,
"is_pending": is_pending,
})
members = list(members_map.values())
# Owner first, then alphabetical
members.sort(key=lambda m: (not m["is_owner"], m["full_name"].lower()))
total_active = sum(
1 for m in members
if any(s["is_active"] and not s["is_pending"] for s in m["stores"])
)
total_pending = sum(
1 for m in members
if any(s["is_pending"] for s in m["stores"])
)
return {
"merchant_name": merchant.name,
"stores": store_list,
"members": members,
"total_members": len(members),
"total_active": total_active,
"total_pending": total_pending,
}
# Singleton instance
merchant_store_service = MerchantStoreService()