"""Organization member provisioning for company admins.""" from __future__ import annotations import secrets from datetime import date from pwdlib import PasswordHash from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.models import Account from app.schemas import OrgMemberCreate, OrgMemberRead from app.services.domain_provisioning import account_is_domain_owner from app.services.org_invitations import send_employee_invitation from app.services.org_sections import get_org_section password_hasher = PasswordHash.recommended() PLACEHOLDER_BIRTHDATE = date(1990, 1, 1) async def can_manage_org_members( session: AsyncSession, account: Account, ) -> bool: if account.account_type != "company" or not account.company_domain: return False if account.member_status == "suspended": return False if account.org_role == "admin": return True return await account_is_domain_owner( session, account.id, account.company_domain, ) async def member_read( session: AsyncSession, member: Account, *, invitation_sent: bool = False, ) -> OrgMemberRead: is_owner = await account_is_domain_owner( session, member.id, member.company_domain, ) role = "owner" if is_owner else member.org_role return OrgMemberRead( id=member.id, email=member.email, invitation_email=member.invitation_email, first_name=member.first_name, last_name=member.last_name, org_role=role, member_status=member.member_status, must_change_password=member.must_change_password, is_domain_owner=is_owner, section_id=member.section_id, section_name=member.section.name if member.section else None, invitation_sent=invitation_sent, created_at=member.created_at, ) async def list_org_members( session: AsyncSession, company_domain: str, ) -> tuple[list[OrgMemberRead], dict[str, int]]: result = await session.scalars( select(Account) .options(selectinload(Account.section)) .where( Account.account_type == "company", Account.company_domain == company_domain, ) .order_by(Account.created_at.asc()) ) members = list(result.all()) items = [await member_read(session, member) for member in members] counts = { "all": len(items), "active": sum(1 for item in items if item.member_status == "active"), "invited": sum(1 for item in items if item.member_status == "invited"), "suspended": sum(1 for item in items if item.member_status == "suspended"), } return items, counts def _placeholder_phone() -> str: digits = "".join(str(secrets.randbelow(10)) for _ in range(10)) return f"+1{digits}" async def create_org_member( session: AsyncSession, *, manager: Account, payload: OrgMemberCreate, ) -> OrgMemberRead: company_domain = manager.company_domain if company_domain is None: raise ValueError("Company domain is required.") email = str(payload.email).lower() if not email.endswith(f"@{company_domain}"): raise ValueError(f"Email must use your company domain @{company_domain}.") invitation_email = ( str(payload.invitation_email).lower() if payload.invitation_email else None ) if payload.send_invitation: if not invitation_email: raise ValueError("A personal invitation email is required.") if invitation_email.endswith(f"@{company_domain}"): raise ValueError( "Use a personal email address for the invitation, not your company domain." ) member_status = "invited" else: member_status = "active" existing = await session.scalar( select(Account).where( func.lower(Account.email) == email, ) ) if existing: raise ValueError("That email address is already registered.") section = None if payload.section_id: section = await get_org_section(session, company_domain, payload.section_id) if section is None: raise ValueError("Selected section was not found.") phone_number = _placeholder_phone() member = Account( account_type="company", email=email, first_name=payload.first_name.strip(), last_name=payload.last_name.strip(), date_of_birth=PLACEHOLDER_BIRTHDATE, phone_number=phone_number, company_domain=company_domain, org_role=payload.org_role, member_status=member_status, invitation_email=invitation_email, must_change_password=( payload.force_password_change if not payload.send_invitation else False ), section_id=section.id if section else None, created_by_id=manager.id, password_hash=password_hasher.hash( payload.password if payload.password else secrets.token_urlsafe(48) ), ) session.add(member) await session.flush() if section: member.section = section invitation_sent = False if payload.send_invitation: try: await send_employee_invitation( session, member=member, manager=manager, ) invitation_sent = True except Exception as error: raise ValueError( f"Could not send invitation email: {error}" ) from error return await member_read(session, member, invitation_sent=invitation_sent) async def update_org_member_role( session: AsyncSession, *, manager: Account, member_id, org_role: str, ) -> OrgMemberRead: company_domain = manager.company_domain if company_domain is None: raise ValueError("Company domain is required.") if not await account_is_domain_owner( session, manager.id, company_domain, ): raise ValueError("Only the domain owner can assign roles.") member = await session.scalar( select(Account).where( Account.id == member_id, Account.company_domain == company_domain, ) ) if member is None: raise ValueError("User not found in your organization.") if await account_is_domain_owner(session, member.id, company_domain): raise ValueError("The domain owner role cannot be changed.") if member.id == manager.id: raise ValueError("You cannot change your own role.") member.org_role = org_role await session.flush() return await member_read(session, member)