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

@@ -168,11 +168,127 @@ async def merchant_team_overview(
db: Session = Depends(get_db),
):
"""
Get team members across all stores owned by the merchant.
Get team members across all merchant stores (member-centric view).
Returns a list of stores with their team members grouped by store.
Returns deduplicated members with per-store role info.
"""
return merchant_store_service.get_merchant_team_overview(db, merchant.id)
return merchant_store_service.get_merchant_team_members(db, merchant.id)
@_account_router.get("/team/stores/{store_id}/roles")
async def merchant_team_store_roles(
store_id: int,
current_user: UserContext = Depends(get_current_merchant_api),
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""Get available roles for a specific store."""
from app.modules.tenancy.services.store_team_service import store_team_service
merchant_store_service.validate_store_ownership(db, merchant.id, store_id)
roles = store_team_service.get_store_roles(db, store_id)
return {"roles": roles, "total": len(roles)}
@_account_router.post("/team/invite")
async def merchant_team_invite(
data: "MerchantTeamInvite",
current_user: UserContext = Depends(get_current_merchant_api),
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""Invite a member to one or more merchant stores."""
from app.modules.tenancy.schemas.team import (
MerchantTeamInviteResponse,
MerchantTeamInviteResult,
)
from app.modules.tenancy.services.store_team_service import store_team_service
# Get the User ORM object (service needs it as inviter)
inviter = merchant_store_service.get_user(db, current_user.id)
results = []
for store_id in data.store_ids:
try:
store = merchant_store_service.validate_store_ownership(
db, merchant.id, store_id
)
store_team_service.invite_team_member(
db,
store=store,
inviter=inviter,
email=data.email,
role_name=data.role_name,
)
results.append(MerchantTeamInviteResult(
store_id=store.id,
store_name=store.name,
success=True,
))
except Exception as e:
results.append(MerchantTeamInviteResult(
store_id=store_id,
store_name=getattr(e, "store_name", str(store_id)),
success=False,
error=str(e),
))
success_count = sum(1 for r in results if r.success)
if success_count == len(results):
message = f"Invitation sent to {data.email} for {success_count} store(s)"
elif success_count > 0:
message = f"Invitation partially sent ({success_count}/{len(results)} stores)"
else:
message = "Invitation failed for all stores"
return MerchantTeamInviteResponse(
message=message,
email=data.email,
results=results,
)
@_account_router.put("/team/stores/{store_id}/members/{user_id}")
async def merchant_team_update_role(
store_id: int,
user_id: int,
role_name: str = Query(..., description="New role name"),
current_user: UserContext = Depends(get_current_merchant_api),
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""Update a member's role in a specific store."""
from app.modules.tenancy.services.store_team_service import store_team_service
store = merchant_store_service.validate_store_ownership(db, merchant.id, store_id)
store_team_service.update_member_role(
db,
store=store,
user_id=user_id,
new_role_name=role_name,
actor_user_id=current_user.id,
)
return {"message": "Role updated successfully"}
@_account_router.delete("/team/stores/{store_id}/members/{user_id}", status_code=204)
async def merchant_team_remove_member(
store_id: int,
user_id: int,
current_user: UserContext = Depends(get_current_merchant_api),
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""Remove a member from a specific store."""
from app.modules.tenancy.services.store_team_service import store_team_service
store = merchant_store_service.validate_store_ownership(db, merchant.id, store_id)
store_team_service.remove_team_member(
db,
store=store,
user_id=user_id,
actor_user_id=current_user.id,
)
@_account_router.get("/profile", response_model=MerchantPortalProfileResponse)