Introduces the multi-tenant SaaS foundation alongside the existing client/team/project model (zero-downtime shim period): Backend: - app/models/organization.py — Organization + OrgRole enum (OWNER/ADMIN/MANAGER/MEMBER/VIEWER) - app/models/membership.py — Membership model with MemberDetail for enriched responses - app/services/membership_service.py — upsert/remove/list/has_org_role helpers - app/api/v1/routes_organizations.py — /organizations CRUD + /members sub-resource + /me/memberships - main.py — registers organizations router - migrations: create memberships collection (unique index) + backfill from pm_client_ids/team members Frontend: - types/api.ts — Organization, OrgRole, Membership, OrganizationCreateRequest types; Client marked @deprecated - hooks/useClients.ts — useOrganizations, useOrganization, useOrgMembers, useAddOrgMember, useUpdateOrgMember, useRemoveOrgMember, useMyMemberships - lib/api.ts — listOrganizations, getOrganization, createOrganization, updateOrganization, listOrgMembers, addOrgMember, updateOrgMember, removeOrgMember, getMyMemberships Reads fall back to the clients collection during transition; all writes go to organizations. Existing /clients endpoints and hooks are untouched. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
124 lines
3.4 KiB
Python
124 lines
3.4 KiB
Python
"""Membership service — queries the memberships collection."""
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
from motor.motor_asyncio import AsyncIOMotorDatabase
|
|
|
|
from ..models.membership import Membership, MemberDetail
|
|
from ..models.organization import OrgRole
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _membership_from_doc(doc: dict) -> Membership:
|
|
return Membership(
|
|
id=str(doc["_id"]),
|
|
user_id=doc["user_id"],
|
|
organization_id=doc["organization_id"],
|
|
role_in_org=OrgRole(doc["role_in_org"]),
|
|
created_at=doc.get("created_at"),
|
|
created_by=doc.get("created_by"),
|
|
)
|
|
|
|
|
|
async def get_memberships_for_user(
|
|
user_id: str,
|
|
db: AsyncIOMotorDatabase,
|
|
) -> list[Membership]:
|
|
cursor = db.memberships.find({"user_id": user_id})
|
|
return [_membership_from_doc(doc) async for doc in cursor]
|
|
|
|
|
|
async def get_membership(
|
|
user_id: str,
|
|
organization_id: str,
|
|
db: AsyncIOMotorDatabase,
|
|
) -> Optional[Membership]:
|
|
doc = await db.memberships.find_one(
|
|
{"user_id": user_id, "organization_id": organization_id}
|
|
)
|
|
return _membership_from_doc(doc) if doc else None
|
|
|
|
|
|
async def has_org_role(
|
|
user_id: str,
|
|
organization_id: str,
|
|
min_role: OrgRole,
|
|
db: AsyncIOMotorDatabase,
|
|
) -> bool:
|
|
membership = await get_membership(user_id, organization_id, db)
|
|
if membership is None:
|
|
return False
|
|
return membership.role_in_org >= min_role
|
|
|
|
|
|
async def upsert_membership(
|
|
user_id: str,
|
|
organization_id: str,
|
|
role_in_org: OrgRole,
|
|
created_by: Optional[str],
|
|
db: AsyncIOMotorDatabase,
|
|
) -> Membership:
|
|
now = _now()
|
|
result = await db.memberships.find_one_and_update(
|
|
{"user_id": user_id, "organization_id": organization_id},
|
|
{
|
|
"$set": {"role_in_org": role_in_org.value, "updated_at": now},
|
|
"$setOnInsert": {
|
|
"user_id": user_id,
|
|
"organization_id": organization_id,
|
|
"created_at": now,
|
|
"created_by": created_by,
|
|
},
|
|
},
|
|
upsert=True,
|
|
return_document=True,
|
|
)
|
|
return _membership_from_doc(result)
|
|
|
|
|
|
async def remove_membership(
|
|
user_id: str,
|
|
organization_id: str,
|
|
db: AsyncIOMotorDatabase,
|
|
) -> bool:
|
|
result = await db.memberships.delete_one(
|
|
{"user_id": user_id, "organization_id": organization_id}
|
|
)
|
|
return result.deleted_count > 0
|
|
|
|
|
|
async def list_org_members(
|
|
organization_id: str,
|
|
db: AsyncIOMotorDatabase,
|
|
) -> list[MemberDetail]:
|
|
pipeline = [
|
|
{"$match": {"organization_id": organization_id}},
|
|
{
|
|
"$lookup": {
|
|
"from": "users",
|
|
"localField": "user_id",
|
|
"foreignField": "_id",
|
|
"as": "user_doc",
|
|
}
|
|
},
|
|
{"$unwind": {"path": "$user_doc", "preserveNullAndEmpty": False}},
|
|
{"$sort": {"created_at": 1}},
|
|
]
|
|
details = []
|
|
async for doc in db.memberships.aggregate(pipeline):
|
|
u = doc["user_doc"]
|
|
details.append(
|
|
MemberDetail(
|
|
membership_id=str(doc["_id"]),
|
|
user_id=doc["user_id"],
|
|
email=u.get("email", ""),
|
|
full_name=u.get("full_name", ""),
|
|
role_in_org=OrgRole(doc["role_in_org"]),
|
|
created_at=doc.get("created_at"),
|
|
)
|
|
)
|
|
return details
|