company flows
This commit is contained in:
parent
6541284a4c
commit
cc3f3f3de5
17 changed files with 1960 additions and 66 deletions
19
backend/app/deps/org_manager.py
Normal file
19
backend/app/deps/org_manager.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
43
backend/app/routers/aliases.py
Normal file
43
backend/app/routers/aliases.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
43
backend/app/routers/groups.py
Normal file
43
backend/app/routers/groups.py
Normal 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
|
||||
66
backend/app/routers/people.py
Normal file
66
backend/app/routers/people.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
117
backend/app/services/mail_aliases.py
Normal file
117
backend/app/services/mail_aliases.py
Normal 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)
|
||||
109
backend/app/services/mail_groups.py
Normal file
109
backend/app/services/mail_groups.py
Normal 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)
|
||||
143
backend/app/services/org_members.py
Normal file
143
backend/app/services/org_members.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue