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,19 @@
from fastapi import Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_postgres_session
from app.models import Account
from app.routers.auth import get_current_account
from app.services.org_members import can_manage_org_members
async def require_org_manager(
account: Account = Depends(get_current_account),
session: AsyncSession = Depends(get_postgres_session),
) -> Account:
if not await can_manage_org_members(session, account):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only company admins can manage organization settings.",
)
return account

View file

@ -7,7 +7,16 @@ from sqlalchemy import text
from app.config import get_settings
from app.database import SessionFactory, engine, mongo_client, mongo_db
from app.models import Base
from app.routers import auth, dns_setup, domains, emails, sticky_notes
from app.routers import (
aliases,
auth,
dns_setup,
domains,
emails,
groups,
people,
sticky_notes,
)
from app.services.domain_provisioning import ensure_platform_domain
from app.services.inbound_smtp import InboundSMTPServer
@ -15,6 +24,29 @@ settings = get_settings()
inbound_server = InboundSMTPServer()
async def migrate_user_org_columns(connection) -> None:
await connection.execute(
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS org_role VARCHAR(20)")
)
await connection.execute(
text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS member_status VARCHAR(20)"
)
)
await connection.execute(
text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS created_by_id UUID "
"REFERENCES users(id)"
)
)
await connection.execute(
text("UPDATE users SET org_role = 'user' WHERE org_role IS NULL")
)
await connection.execute(
text("UPDATE users SET member_status = 'active' WHERE member_status IS NULL")
)
async def migrate_legacy_user_tables(connection) -> None:
"""Promote the former auth table to users without discarding old profiles."""
tables = (
@ -44,6 +76,7 @@ async def lifespan(_: FastAPI):
async with engine.begin() as connection:
await migrate_legacy_user_tables(connection)
await connection.run_sync(Base.metadata.create_all)
await migrate_user_org_columns(connection)
async with SessionFactory() as session:
await ensure_platform_domain(session)
await session.commit()
@ -124,6 +157,9 @@ app.include_router(emails.router, prefix=settings.api_prefix)
app.include_router(sticky_notes.router, prefix=settings.api_prefix)
app.include_router(auth.router, prefix=settings.api_prefix)
app.include_router(domains.router, prefix=settings.api_prefix)
app.include_router(people.router, prefix=settings.api_prefix)
app.include_router(groups.router, prefix=settings.api_prefix)
app.include_router(aliases.router, prefix=settings.api_prefix)
app.include_router(dns_setup.router, prefix=settings.api_prefix)

View file

@ -41,6 +41,13 @@ class Account(Base):
date_of_birth: Mapped[date] = mapped_column(Date)
phone_number: Mapped[str] = mapped_column(String(24), unique=True)
company_domain: Mapped[str | None] = mapped_column(String(253), nullable=True)
org_role: Mapped[str] = mapped_column(String(20), default="user")
member_status: Mapped[str] = mapped_column(String(20), default="active")
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True,
)
password_hash: Mapped[str] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
@ -127,3 +134,93 @@ class SmtpCredential(Base):
server_default=func.now(),
)
domain: Mapped[MailDomain] = relationship(back_populates="smtp_credentials")
class MailGroup(Base):
__tablename__ = "mail_groups"
__table_args__ = (
CheckConstraint(
"group_type IN ('distribution', 'dynamic')",
name="ck_mail_groups_group_type",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
company_domain: Mapped[str] = mapped_column(String(253), index=True)
name: Mapped[str] = mapped_column(String(120))
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
group_type: Mapped[str] = mapped_column(String(20), default="distribution")
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
)
members: Mapped[list["MailGroupMember"]] = relationship(
back_populates="group",
cascade="all, delete-orphan",
)
class MailGroupMember(Base):
__tablename__ = "mail_group_members"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
group_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("mail_groups.id"),
index=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
index=True,
)
group: Mapped[MailGroup] = relationship(back_populates="members")
account: Mapped[Account] = relationship()
class MailAlias(Base):
__tablename__ = "mail_aliases"
__table_args__ = (
CheckConstraint(
"alias_type IN ('user', 'group')",
name="ck_mail_aliases_alias_type",
),
CheckConstraint(
"status IN ('active', 'disabled')",
name="ck_mail_aliases_status",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
company_domain: Mapped[str] = mapped_column(String(253), index=True)
alias_address: Mapped[str] = mapped_column(String(320), unique=True, index=True)
forwards_to: Mapped[str] = mapped_column(String(320))
alias_type: Mapped[str] = mapped_column(String(20), default="user")
is_domain_alias: Mapped[bool] = mapped_column(Boolean, default=False)
status: Mapped[str] = mapped_column(String(20), default="active")
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
)

View file

@ -0,0 +1,43 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_postgres_session
from app.deps.org_manager import require_org_manager
from app.models import Account
from app.schemas import MailAliasCreate, MailAliasListResponse, MailAliasRead
from app.services.mail_aliases import create_mail_alias, list_mail_aliases
router = APIRouter(prefix="/aliases", tags=["aliases"])
@router.get("", response_model=MailAliasListResponse)
async def get_aliases(
manager: Account = Depends(require_org_manager),
session: AsyncSession = Depends(get_postgres_session),
) -> MailAliasListResponse:
items, counts = await list_mail_aliases(session, manager.company_domain)
return MailAliasListResponse(items=items, counts=counts)
@router.post("", response_model=MailAliasRead, status_code=status.HTTP_201_CREATED)
async def add_alias(
payload: MailAliasCreate,
manager: Account = Depends(require_org_manager),
session: AsyncSession = Depends(get_postgres_session),
) -> MailAliasRead:
try:
alias = await create_mail_alias(session, manager=manager, payload=payload)
await session.commit()
return alias
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(error),
) from error
except IntegrityError as error:
await session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="That alias could not be created.",
) from error

View file

@ -176,6 +176,8 @@ async def register(
date_of_birth=payload.date_of_birth,
phone_number=phone_number,
company_domain=company_domain,
org_role="owner" if company_domain else "user",
member_status="active",
password_hash=password_hash.hash(payload.password),
)
session.add(account)

View file

@ -0,0 +1,43 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_postgres_session
from app.deps.org_manager import require_org_manager
from app.models import Account
from app.schemas import MailGroupCreate, MailGroupListResponse, MailGroupRead
from app.services.mail_groups import create_mail_group, list_mail_groups
router = APIRouter(prefix="/groups", tags=["groups"])
@router.get("", response_model=MailGroupListResponse)
async def get_groups(
manager: Account = Depends(require_org_manager),
session: AsyncSession = Depends(get_postgres_session),
) -> MailGroupListResponse:
items, counts = await list_mail_groups(session, manager.company_domain)
return MailGroupListResponse(items=items, counts=counts)
@router.post("", response_model=MailGroupRead, status_code=status.HTTP_201_CREATED)
async def add_group(
payload: MailGroupCreate,
manager: Account = Depends(require_org_manager),
session: AsyncSession = Depends(get_postgres_session),
) -> MailGroupRead:
try:
group = await create_mail_group(session, manager=manager, payload=payload)
await session.commit()
return group
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(error),
) from error
except IntegrityError as error:
await session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="That group could not be created.",
) from error

View file

@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_postgres_session
from app.models import Account
from app.routers.auth import get_current_account, validate_password
from app.schemas import OrgMemberCreate, OrgMemberListResponse, OrgMemberRead
from app.services.org_members import (
can_manage_org_members,
create_org_member,
list_org_members,
)
router = APIRouter(prefix="/people", tags=["people"])
async def require_org_manager(
account: Account = Depends(get_current_account),
session: AsyncSession = Depends(get_postgres_session),
) -> Account:
if not await can_manage_org_members(session, account):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only company admins can manage people.",
)
return account
@router.get("", response_model=OrgMemberListResponse)
async def get_people(
manager: Account = Depends(require_org_manager),
session: AsyncSession = Depends(get_postgres_session),
) -> OrgMemberListResponse:
items, counts = await list_org_members(session, manager.company_domain)
return OrgMemberListResponse(items=items, counts=counts)
@router.post("", response_model=OrgMemberRead, status_code=status.HTTP_201_CREATED)
async def add_person(
payload: OrgMemberCreate,
manager: Account = Depends(require_org_manager),
session: AsyncSession = Depends(get_postgres_session),
) -> OrgMemberRead:
if not payload.send_invitation:
validate_password(payload.password or "")
try:
member = await create_org_member(
session,
manager=manager,
payload=payload,
)
await session.commit()
return member
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(error),
) from error
except IntegrityError as error:
await session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="An account with those details already exists.",
) from error

View file

@ -51,6 +51,81 @@ class AuthResponse(BaseModel):
account: AccountRead
class OrgMemberCreate(BaseModel):
first_name: str = Field(min_length=1, max_length=80)
last_name: str = Field(min_length=1, max_length=80)
email: EmailStr
org_role: Literal["user", "admin"] = "user"
send_invitation: bool = True
password: str | None = Field(default=None, min_length=8, max_length=128)
class OrgMemberRead(BaseModel):
id: UUID
email: EmailStr
first_name: str
last_name: str
org_role: str
member_status: str
is_domain_owner: bool = False
groups: list[str] = Field(default_factory=list)
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class OrgMemberListResponse(BaseModel):
items: list[OrgMemberRead]
counts: dict[str, int]
class MailGroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
email: EmailStr
group_type: Literal["distribution", "dynamic"] = "distribution"
member_ids: list[UUID] = Field(default_factory=list)
class MailGroupRead(BaseModel):
id: UUID
name: str
email: EmailStr
group_type: str
member_count: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class MailGroupListResponse(BaseModel):
items: list[MailGroupRead]
counts: dict[str, int]
class MailAliasCreate(BaseModel):
alias_address: EmailStr
forwards_to: EmailStr
alias_type: Literal["user", "group"] = "user"
is_domain_alias: bool = False
class MailAliasRead(BaseModel):
id: UUID
alias_address: EmailStr
forwards_to: EmailStr
alias_type: str
is_domain_alias: bool
status: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class MailAliasListResponse(BaseModel):
items: list[MailAliasRead]
counts: dict[str, int]
class EmailCreate(BaseModel):
recipients: list[EmailStr] = Field(min_length=1)
cc: list[EmailStr] = Field(default_factory=list)

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)