company flows

This commit is contained in:
debu-2 2026-10-04 20:01:29 +05:30
parent 6541284a4c
commit cc3f3f3de5
17 changed files with 1960 additions and 66 deletions

View file

@ -0,0 +1,117 @@
"""Email alias management."""
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Account, MailAlias, MailGroup
from app.schemas import MailAliasCreate, MailAliasRead
def _alias_read(alias: MailAlias) -> MailAliasRead:
return MailAliasRead(
id=alias.id,
alias_address=alias.alias_address,
forwards_to=alias.forwards_to,
alias_type=alias.alias_type,
is_domain_alias=alias.is_domain_alias,
status=alias.status,
created_at=alias.created_at,
)
async def list_mail_aliases(
session: AsyncSession,
company_domain: str,
) -> tuple[list[MailAliasRead], dict[str, int]]:
result = await session.scalars(
select(MailAlias)
.where(MailAlias.company_domain == company_domain)
.order_by(MailAlias.created_at.desc())
)
aliases = list(result.all())
items = [_alias_read(alias) for alias in aliases]
counts = {
"all": len(items),
"domain": sum(1 for item in items if item.is_domain_alias),
}
return items, counts
async def _destination_exists(
session: AsyncSession,
company_domain: str,
address: str,
) -> bool:
normalized = address.lower()
if not normalized.endswith(f"@{company_domain}"):
return False
account = await session.scalar(
select(Account).where(func.lower(Account.email) == normalized)
)
if account:
return True
group = await session.scalar(
select(MailGroup).where(func.lower(MailGroup.email) == normalized)
)
return group is not None
async def create_mail_alias(
session: AsyncSession,
*,
manager: Account,
payload: MailAliasCreate,
) -> MailAliasRead:
company_domain = manager.company_domain
if company_domain is None:
raise ValueError("Company domain is required.")
alias_address = str(payload.alias_address).lower()
forwards_to = str(payload.forwards_to).lower()
if not alias_address.endswith(f"@{company_domain}"):
raise ValueError(f"Alias address must use @{company_domain}.")
if not forwards_to.endswith(f"@{company_domain}"):
raise ValueError(f"Forwarding address must use @{company_domain}.")
if alias_address == forwards_to:
raise ValueError("Alias cannot forward to itself.")
existing = await session.scalar(
select(MailAlias).where(
func.lower(MailAlias.alias_address) == alias_address
)
)
if existing:
raise ValueError("That alias address is already in use.")
account_conflict = await session.scalar(
select(Account).where(func.lower(Account.email) == alias_address)
)
if account_conflict:
raise ValueError("That address is already a user mailbox.")
group_conflict = await session.scalar(
select(MailGroup).where(func.lower(MailGroup.email) == alias_address)
)
if group_conflict:
raise ValueError("That address is already a group mailbox.")
if not await _destination_exists(session, company_domain, forwards_to):
raise ValueError("Forwarding address must be an existing user or group.")
alias = MailAlias(
company_domain=company_domain,
alias_address=alias_address,
forwards_to=forwards_to,
alias_type=payload.alias_type,
is_domain_alias=payload.is_domain_alias,
status="active",
created_by_id=manager.id,
)
session.add(alias)
await session.flush()
return _alias_read(alias)

View file

@ -0,0 +1,109 @@
"""Distribution group management."""
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Account, MailGroup, MailGroupMember
from app.schemas import MailGroupCreate, MailGroupRead
def _group_read(group: MailGroup) -> MailGroupRead:
return MailGroupRead(
id=group.id,
name=group.name,
email=group.email,
group_type=group.group_type,
member_count=len(group.members),
created_at=group.created_at,
)
async def list_mail_groups(
session: AsyncSession,
company_domain: str,
) -> tuple[list[MailGroupRead], dict[str, int]]:
result = await session.scalars(
select(MailGroup)
.options(selectinload(MailGroup.members))
.where(MailGroup.company_domain == company_domain)
.order_by(MailGroup.created_at.desc())
)
groups = list(result.all())
items = [_group_read(group) for group in groups]
counts = {
"all": len(items),
"distribution": sum(1 for item in items if item.group_type == "distribution"),
"dynamic": sum(1 for item in items if item.group_type == "dynamic"),
}
return items, counts
async def _validate_member_ids(
session: AsyncSession,
company_domain: str,
member_ids: list,
) -> list[Account]:
if not member_ids:
return []
result = await session.scalars(
select(Account).where(
Account.id.in_(member_ids),
Account.company_domain == company_domain,
Account.member_status != "suspended",
)
)
members = list(result.all())
if len(members) != len(set(member_ids)):
raise ValueError("One or more selected members are invalid.")
return members
async def create_mail_group(
session: AsyncSession,
*,
manager: Account,
payload: MailGroupCreate,
) -> MailGroupRead:
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"Group email must use @{company_domain}.")
existing = await session.scalar(
select(MailGroup).where(func.lower(MailGroup.email) == email)
)
if existing:
raise ValueError("That group email address is already in use.")
members = await _validate_member_ids(
session,
company_domain,
payload.member_ids,
)
group = MailGroup(
company_domain=company_domain,
name=payload.name.strip(),
email=email,
group_type=payload.group_type,
created_by_id=manager.id,
)
session.add(group)
await session.flush()
for member in members:
session.add(
MailGroupMember(
group_id=group.id,
account_id=member.id,
)
)
await session.flush()
await session.refresh(group, attribute_names=["members"])
return _group_read(group)

View file

@ -0,0 +1,143 @@
"""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 app.models import Account
from app.schemas import OrgMemberCreate, OrgMemberRead
from app.services.domain_provisioning import account_is_domain_owner
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,
) -> 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,
first_name=member.first_name,
last_name=member.last_name,
org_role=role,
member_status=member.member_status,
is_domain_owner=is_owner,
groups=[],
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)
.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}"
def _email_local_part(email: str) -> str:
return email.split("@", 1)[0].lower()
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}.")
if payload.send_invitation:
raw_password = secrets.token_urlsafe(24)
member_status = "invited"
else:
if not payload.password:
raise ValueError("Password is required when invitation email is disabled.")
raw_password = payload.password
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.")
phone_number = _placeholder_phone()
while await session.scalar(
select(Account).where(Account.phone_number == phone_number)
):
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,
created_by_id=manager.id,
password_hash=password_hasher.hash(raw_password),
)
session.add(member)
await session.flush()
return await member_read(session, member)