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