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:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user