video-accessibility/backend/app/services/membership_service.py
Vadym Samoilenko 6f1be645ce feat(saas): Phase 0+1 — Organization/Membership entities and dev branch
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>
2026-04-27 16:46:24 +01:00

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