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

@@ -118,6 +118,12 @@ from app.modules.tenancy.schemas.team import (
InvitationAccept,
InvitationAcceptResponse,
InvitationResponse,
MerchantTeamInvite,
MerchantTeamInviteResponse,
MerchantTeamMemberResponse,
MerchantTeamMemberStoreInfo,
MerchantTeamOverviewResponse,
MerchantTeamStoreInfo,
PermissionCheckRequest,
PermissionCheckResponse,
RoleBase,

View File

@@ -315,3 +315,89 @@ class TeamErrorResponse(BaseModel):
error_code: str
message: str
details: dict | None = None
# ============================================================================
# Merchant Team Schemas (Hub View)
# ============================================================================
class MerchantTeamMemberStoreInfo(BaseModel):
"""A member's role/status in one specific store."""
store_id: int
store_name: str
store_code: str
role_name: str | None = None
role_id: int | None = None
is_active: bool = True
is_pending: bool = False
class MerchantTeamMemberResponse(BaseModel):
"""A team member aggregated across all merchant stores."""
user_id: int
email: EmailStr
first_name: str | None = None
last_name: str | None = None
full_name: str
stores: list[MerchantTeamMemberStoreInfo] = Field(default_factory=list)
is_owner: bool = False
class MerchantTeamStoreInfo(BaseModel):
"""Compact store info for the merchant team overview."""
id: int
name: str
code: str
class MerchantTeamOverviewResponse(BaseModel):
"""Merchant team overview with member-centric view."""
merchant_name: str
stores: list[MerchantTeamStoreInfo]
members: list[MerchantTeamMemberResponse]
total_members: int
total_active: int
total_pending: int
class MerchantTeamInvite(BaseModel):
"""Schema for inviting a member to merchant stores."""
email: EmailStr
first_name: str | None = Field(None, max_length=100)
last_name: str | None = Field(None, max_length=100)
store_ids: list[int] = Field(..., min_length=1, description="Store IDs to invite to")
role_name: str = Field("staff", description="Role name for all selected stores")
@field_validator("role_name")
@classmethod
def validate_role_name(cls, v):
"""Validate role name is in allowed presets."""
allowed_roles = ["manager", "staff", "support", "viewer", "marketing"]
if v.lower() not in allowed_roles:
raise ValueError(
f"Role name must be one of: {', '.join(allowed_roles)}"
)
return v.lower()
class MerchantTeamInviteResult(BaseModel):
"""Per-store invite result."""
store_id: int
store_name: str
success: bool
error: str | None = None
class MerchantTeamInviteResponse(BaseModel):
"""Response for merchant team invite (multi-store)."""
message: str
email: EmailStr
results: list[MerchantTeamInviteResult]