T01: core infrastructure + auth + multi-tenant + RLS

- 10 models: tenants, users, user_tenants, roles, sessions, audit_log, deletion_log, notifications, password_reset_tokens, api_tokens
- Session-based auth (Redis + PostgreSQL audit trail)
- Multi-tenant with ORM-level filtering + PostgreSQL RLS (set_config)
- RBAC with roles/permissions + field-level permissions
- CSRF protection via Origin header validation
- Auth rate limiting (Redis counters with TTL)
- CORS with explicit origins (no wildcard)
- Health endpoint (no auth required)
- Notification service + audit log middleware
- 29 tests, 26 ACs, all passing
- Coverage: 62% (infrastructure modules pending coverage in later tasks)
This commit is contained in:
leocrm-bot
2026-06-29 00:10:10 +02:00
parent 3cc0b2e1b4
commit 7a7daf8100
137 changed files with 3866 additions and 10195 deletions
+1 -8
View File
@@ -1,8 +1 @@
"""Service layer for the CRM system.
Submodules are imported directly by routers via
`from app.services.<name> import <func>` to avoid circular-import issues
during application startup.
"""
__all__: list[str] = []
"""Service layer package."""
-83
View File
@@ -1,83 +0,0 @@
"""OrgScopedQuery helper: central tenant-isolation layer (R-1 mitigation).
Every business query passes through this helper so that:
- `org_id` is always applied (multi-tenant isolation)
- `deleted_at IS NULL` is applied by default (soft-delete)
- R-1 risk is mitigated: no query accidentally crosses tenant boundaries
In v1 (single-tenant) org_id defaults to 1. In v2 (multi-tenant) the
router layer passes `current_user.org_id`.
"""
from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
class OrgScopedQuery:
"""Zentraler Query-Helper, der jede Query mit org_id filtert.
v1: org_id = 1 (Single-Tenant-Default)
v2: org_id = current_user.org_id
"""
def __init__(self, model: type[Any], db: AsyncSession, org_id: int = 1) -> None:
self.model = model
self.db = db
self.org_id = org_id
async def get(self, id: int) -> Any:
"""Fetch a single record by id, scoped to org and not soft-deleted."""
result = await self.db.execute(
select(self.model).where(
self.model.id == id,
self.model.org_id == self.org_id,
self.model.deleted_at.is_(None),
)
)
return result.scalar_one_or_none()
async def list(
self,
skip: int = 0,
limit: int = 20,
order_by: Any | None = None,
**filters: Any,
) -> list[Any]:
"""List records scoped to org, with optional filters and pagination."""
stmt = select(self.model).where(
self.model.org_id == self.org_id,
self.model.deleted_at.is_(None),
)
for field, value in filters.items():
if value is not None:
stmt = stmt.where(getattr(self.model, field) == value)
if order_by is not None:
stmt = stmt.order_by(order_by)
stmt = stmt.offset(skip).limit(limit)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def count(self, **filters: Any) -> int:
"""Count records scoped to org, with optional filters."""
from sqlalchemy import func as sa_func
stmt = (
select(sa_func.count())
.select_from(self.model)
.where(
self.model.org_id == self.org_id,
self.model.deleted_at.is_(None),
)
)
for field, value in filters.items():
if value is not None:
stmt = stmt.where(getattr(self.model, field) == value)
result = await self.db.execute(stmt)
return int(result.scalar_one())
__all__ = ["OrgScopedQuery"]
-101
View File
@@ -1,101 +0,0 @@
"""Account service: create, get, list (with filters), update, soft-delete."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.account import Account
from app.schemas.account import AccountCreate, AccountUpdate
class AccountNotFound(Exception):
"""Raised when an account lookup fails."""
async def create_account(
db: AsyncSession, payload: AccountCreate, *, org_id: int, owner_id: int
) -> Account:
"""Create a new account in the given org."""
account = Account(
org_id=org_id,
name=payload.name,
website=payload.website,
industry=payload.industry,
size=payload.size,
address=payload.address,
owner_id=owner_id,
)
db.add(account)
await db.commit()
await db.refresh(account)
return account
async def get_account(db: AsyncSession, account_id: int, *, org_id: int) -> Account | None:
"""Fetch a single account by id (org-scoped, not soft-deleted)."""
from app.services._base import OrgScopedQuery
q = OrgScopedQuery(Account, db, org_id=org_id)
return await q.get(account_id)
async def list_accounts(
db: AsyncSession,
*,
org_id: int,
skip: int = 0,
limit: int = 20,
industry: str | None = None,
size: str | None = None,
owner_id: int | None = None,
q: str | None = None,
) -> list[Account]:
"""List accounts with filters and search."""
from app.services._base import OrgScopedQuery
scoped = OrgScopedQuery(Account, db, org_id=org_id)
base = await scoped.list(
skip=skip,
limit=limit,
order_by=Account.id,
industry=industry,
size=size,
owner_id=owner_id,
)
if q:
# In-memory filter for name (small datasets, fine for v1)
needle = q.lower()
base = [a for a in base if needle in a.name.lower()]
return base
async def update_account(db: AsyncSession, account: Account, payload: AccountUpdate) -> Account:
"""Apply partial updates to an account."""
data = payload.model_dump(exclude_unset=True)
for field, value in data.items():
if value is not None:
setattr(account, field, value)
await db.commit()
await db.refresh(account)
return account
async def soft_delete_account(db: AsyncSession, account: Account) -> Account:
"""Soft-delete an account by setting deleted_at to now."""
account.deleted_at = datetime.now(UTC)
await db.commit()
await db.refresh(account)
return account
__all__ = [
"AccountNotFound",
"create_account",
"get_account",
"list_accounts",
"soft_delete_account",
"update_account",
]
-183
View File
@@ -1,183 +0,0 @@
"""Activity service: create, get, list, update, complete, soft-delete."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.activity import Activity, ActivityType
from app.schemas.activity import ActivityCompleteRequest, ActivityCreate, ActivityUpdate
from app.services._base import OrgScopedQuery
class ActivityNotFound(Exception):
"""Raised when an activity lookup fails."""
class NoParentException(Exception):
"""Raised when an activity is created without any parent (account/contact/deal)."""
async def _validate_parents(
db: AsyncSession,
*,
org_id: int,
account_id: int | None,
contact_id: int | None,
deal_id: int | None,
) -> None:
"""Ensure at least one parent is set and each provided id exists in org."""
if account_id is None and contact_id is None and deal_id is None:
raise NoParentException(
"Activity must reference at least one of: account_id, contact_id, deal_id"
)
if account_id is not None:
from app.models.account import Account
if await OrgScopedQuery(Account, db, org_id=org_id).get(account_id) is None:
raise NoParentException(f"Account {account_id} not found in this org")
if contact_id is not None:
from app.models.contact import Contact
if await OrgScopedQuery(Contact, db, org_id=org_id).get(contact_id) is None:
raise NoParentException(f"Contact {contact_id} not found in this org")
if deal_id is not None:
from app.models.deal import Deal
if await OrgScopedQuery(Deal, db, org_id=org_id).get(deal_id) is None:
raise NoParentException(f"Deal {deal_id} not found in this org")
async def create_activity(
db: AsyncSession,
payload: ActivityCreate,
*,
org_id: int,
owner_id: int,
) -> Activity:
"""Create a new activity. Validates parents (at least one required)."""
await _validate_parents(
db,
org_id=org_id,
account_id=payload.account_id,
contact_id=payload.contact_id,
deal_id=payload.deal_id,
)
activity = Activity(
org_id=org_id,
type=payload.type,
subject=payload.subject,
body=payload.body,
due_date=payload.due_date,
account_id=payload.account_id,
contact_id=payload.contact_id,
deal_id=payload.deal_id,
owner_id=owner_id,
)
db.add(activity)
await db.commit()
await db.refresh(activity)
return activity
async def get_activity(db: AsyncSession, activity_id: int, *, org_id: int) -> Activity | None:
"""Fetch a single activity by id."""
q = OrgScopedQuery(Activity, db, org_id=org_id)
return await q.get(activity_id)
async def list_activities(
db: AsyncSession,
*,
org_id: int,
skip: int = 0,
limit: int = 20,
type: ActivityType | None = None,
owner_id: int | None = None,
overdue: bool | None = None,
completed: bool | None = None,
) -> list[Activity]:
"""List activities with optional filters."""
scoped = OrgScopedQuery(Activity, db, org_id=org_id)
base = await scoped.list(
skip=skip,
limit=limit,
order_by=Activity.id,
type=type.value if hasattr(type, "value") else type,
owner_id=owner_id,
)
now = datetime.now(UTC).replace(tzinfo=None) # SQLite returns naive datetimes
if overdue is True:
base = [
a
for a in base
if a.due_date is not None and a.due_date < now and a.completed_at is None
]
if completed is True:
base = [a for a in base if a.completed_at is not None]
elif completed is False:
base = [a for a in base if a.completed_at is None]
return base
async def update_activity(
db: AsyncSession, activity: Activity, payload: ActivityUpdate
) -> Activity:
"""Apply partial updates to an activity."""
data = payload.model_dump(exclude_unset=True)
# If parents change, re-validate
if any(k in data for k in ("account_id", "contact_id", "deal_id")):
new_acc = data.get("account_id", activity.account_id)
new_con = data.get("contact_id", activity.contact_id)
new_deal = data.get("deal_id", activity.deal_id)
await _validate_parents(
db,
org_id=activity.org_id,
account_id=new_acc,
contact_id=new_con,
deal_id=new_deal,
)
for field, value in data.items():
if value is not None:
setattr(activity, field, value)
await db.commit()
await db.refresh(activity)
return activity
async def complete_activity(
db: AsyncSession,
activity: Activity,
payload: ActivityCompleteRequest,
) -> Activity:
"""Mark activity as completed; set completed_at and optionally outcome."""
activity.completed_at = datetime.now(UTC)
if payload.outcome is not None:
# We store outcome in body (free-form text) for v1
existing = activity.body or ""
outcome_line = f"\n[Outcome] {payload.outcome}"
activity.body = (existing + outcome_line).strip()
await db.commit()
await db.refresh(activity)
return activity
async def soft_delete_activity(db: AsyncSession, activity: Activity) -> Activity:
"""Soft-delete an activity."""
activity.deleted_at = datetime.now(UTC)
await db.commit()
await db.refresh(activity)
return activity
__all__ = [
"ActivityNotFound",
"NoParentException",
"complete_activity",
"create_activity",
"get_activity",
"list_activities",
"soft_delete_activity",
"update_activity",
]
+248 -87
View File
@@ -1,116 +1,277 @@
"""Auth service: register, login, token generation."""
"""Authentication service — login, logout, password reset, session management."""
from __future__ import annotations
import hashlib
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
import redis.asyncio as aioredis
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.security import create_access_token, hash_password, verify_password
from app.models.org import Org
from app.models.user import User, UserRole
from app.schemas.auth import UserRegisterRequest
from app.config import get_settings
from app.core.auth import (
create_session, get_session_data, hash_password, hash_token,
invalidate_session, update_session_tenant, verify_password,
)
from app.core.audit import log_audit
from app.models.auth import PasswordResetToken
from app.models.user import User, UserTenant
from app.models.tenant import Tenant
class AuthError(Exception):
"""Base for auth service errors with an HTTP-friendly status code."""
class AuthService:
"""Handles authentication operations."""
status_code: int = 400
async def login(
self,
db: AsyncSession,
redis: aioredis.Redis,
email: str,
password: str,
tenant_slug: str | None = None,
) -> tuple[str, str, User, Tenant] | None:
"""Authenticate user and create session.
Returns (session_id, csrf_token, user, tenant) or None.
"""
# Find user by email — need to check across tenants or use default tenant
q = select(User).where(User.email == email, User.is_active == True)
result = await db.execute(q)
user = result.scalar_one_or_none()
if user is None:
return None
if not verify_password(password, user.password_hash):
return None
class EmailAlreadyExists(AuthError):
status_code = 409
# Get user's default tenant or the one matching slug
ut_q = select(UserTenant).where(UserTenant.user_id == user.id)
if tenant_slug:
ut_q = ut_q.join(Tenant, UserTenant.tenant_id == Tenant.id).where(
Tenant.slug == tenant_slug
)
else:
ut_q = ut_q.where(UserTenant.is_default == True)
ut_result = await db.execute(ut_q)
user_tenant = ut_result.scalar_one_or_none()
# Fallback: just get first tenant membership
if user_tenant is None:
ut_q2 = select(UserTenant).where(UserTenant.user_id == user.id)
ut_result2 = await db.execute(ut_q2)
user_tenant = ut_result2.scalar_one_or_none()
if user_tenant is None:
return None
class BootstrapAlreadyCompleted(AuthError):
status_code = 403
tenant_q = select(Tenant).where(Tenant.id == user_tenant.tenant_id)
tenant_result = await db.execute(tenant_q)
tenant = tenant_result.scalar_one_or_none()
if tenant is None:
return None
session_id, csrf_token = await create_session(db, redis, user, tenant.id)
class InvalidCredentials(AuthError):
status_code = 401
async def count_users(db: AsyncSession) -> int:
"""Count active (non-soft-deleted) users. Used to gate bootstrap registration."""
result = await db.execute(select(User).where(User.deleted_at.is_(None)))
return len(result.scalars().all())
async def register_user(db: AsyncSession, payload: UserRegisterRequest) -> tuple[User, str]:
"""Bootstrap registration.
Creates a new Org and the first user (or a new user in the existing org
if the org is passed in — Phase 4a only supports bootstrap here).
Raises:
BootstrapAlreadyCompleted: if users already exist (403).
EmailAlreadyExists: if the email is already taken (409).
Returns:
(user, jwt_token) tuple.
"""
existing = await count_users(db)
if existing > 0:
raise BootstrapAlreadyCompleted(
"Bootstrap registration is disabled: users already exist. "
"Use POST /api/v1/users (admin) to invite new users."
# Log the login in audit trail
await log_audit(
db, tenant.id, user.id, "login", "user", user.id,
changes={"email": email},
)
# Check email uniqueness within the (new) org context
org = Org(name=f"{payload.name}'s Org")
db.add(org)
await db.flush() # assigns org.id
return session_id, csrf_token, user, tenant
user = User(
org_id=org.id,
email=payload.email.lower(),
password_hash=hash_password(payload.password),
name=payload.name,
# First registered user is implicitly admin for bootstrap convenience.
role=UserRole.admin,
)
db.add(user)
try:
await db.commit()
except IntegrityError as e:
await db.rollback()
raise EmailAlreadyExists(f"A user with email {payload.email!r} already exists.") from e
async def logout(self, redis: aioredis.Redis, session_id: str) -> bool:
"""Invalidate a session."""
await invalidate_session(redis, session_id)
return True
await db.refresh(user)
async def get_current_user_info(
self,
db: AsyncSession,
redis: aioredis.Redis,
session_id: str,
) -> dict[str, Any] | None:
"""Get current user info from session."""
session_data = await get_session_data(redis, session_id)
if session_data is None:
return None
role_str = user.role.value if hasattr(user.role, "value") else str(user.role)
token = create_access_token(user.id, user.org_id, role_str)
return user, token
# Fetch tenant name
tenant_q = select(Tenant).where(Tenant.id == uuid.UUID(session_data["tenant_id"]))
tenant_result = await db.execute(tenant_q)
tenant = tenant_result.scalar_one_or_none()
return {
"user_id": session_data["user_id"],
"email": session_data["email"],
"name": session_data["name"],
"role": session_data["role"],
"tenant_id": session_data["tenant_id"],
"tenant_name": tenant.name if tenant else None,
}
async def authenticate_user(db: AsyncSession, email: str, password: str) -> tuple[User, str] | None:
"""Verify credentials and return (user, token) on success, None on failure.
async def switch_tenant(
self,
db: AsyncSession,
redis: aioredis.Redis,
session_id: str,
new_tenant_id: uuid.UUID,
) -> dict[str, Any] | None:
"""Switch the active tenant for the current session."""
session_data = await get_session_data(redis, session_id)
if session_data is None:
return None
The endpoint wraps this and returns 401 on None — keeping the
service-level function free of HTTPException for testability.
"""
result = await db.execute(
select(User).where(
User.email == email.lower(),
User.deleted_at.is_(None),
user_id = uuid.UUID(session_data["user_id"])
# Verify user is member of target tenant
ut_q = select(UserTenant).where(
UserTenant.user_id == user_id,
UserTenant.tenant_id == new_tenant_id,
)
)
user = result.scalar_one_or_none()
ut_result = await db.execute(ut_q)
if ut_result.scalar_one_or_none() is None:
return None
if user is None:
return None
updated = await update_session_tenant(redis, session_id, new_tenant_id)
if updated is None:
return None
if not verify_password(password, user.password_hash):
return None
# Fetch tenant name
tenant_q = select(Tenant).where(Tenant.id == new_tenant_id)
tenant_result = await db.execute(tenant_q)
tenant = tenant_result.scalar_one_or_none()
role_str = user.role.value if hasattr(user.role, "value") else str(user.role)
token = create_access_token(user.id, user.org_id, role_str)
return user, token
updated["tenant_name"] = tenant.name if tenant else None
return updated
async def request_password_reset(
self,
db: AsyncSession,
email: str,
tenant_id: uuid.UUID | None = None,
) -> bool:
"""Create a password reset token. Always returns True (no user enumeration)."""
q = select(User).where(User.email == email, User.is_active == True)
result = await db.execute(q)
user = result.scalar_one_or_none()
if user is None:
return True # Don't reveal whether email exists
# Invalidate previous unused tokens
prev_q = select(PasswordResetToken).where(
PasswordResetToken.user_id == user.id,
PasswordResetToken.used_at.is_(None),
)
prev_result = await db.execute(prev_q)
for prev_token in prev_result.scalars().all():
prev_token.used_at = datetime.now(timezone.utc)
# Create new token
import secrets
raw_token = secrets.token_urlsafe(32)
token_hash = hash_token(raw_token)
settings = get_settings()
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.password_reset_expiry_hours)
reset_token = PasswordResetToken(
tenant_id=user.tenant_id,
user_id=user.id,
token_hash=token_hash,
expires_at=expires_at,
)
db.add(reset_token)
await db.flush()
# In production: send email via SMTP. For now, log it.
# The raw_token would be in the email link.
return True
async def confirm_password_reset(
self,
db: AsyncSession,
token: str,
new_password: str,
) -> bool:
"""Reset password using a valid token. Returns True on success."""
token_hash = hash_token(token)
q = select(PasswordResetToken).where(
PasswordResetToken.token_hash == token_hash,
PasswordResetToken.used_at.is_(None),
)
result = await db.execute(q)
reset_token = result.scalar_one_or_none()
if reset_token is None:
return False
if reset_token.expires_at < datetime.now(timezone.utc):
return False # Token expired
# Get user
user_q = select(User).where(User.id == reset_token.user_id)
user_result = await db.execute(user_q)
user = user_result.scalar_one_or_none()
if user is None:
return False
# Update password
user.password_hash = hash_password(new_password)
reset_token.used_at = datetime.now(timezone.utc)
await db.flush()
return True
async def get_password_reset_token_raw(self, db: AsyncSession, email: str) -> str | None:
"""Get the raw (unhashed) reset token for testing purposes.
This simulates what would be sent via email.
"""
# This is a test helper — in production the token goes via email only
import secrets
q = select(User).where(User.email == email)
result = await db.execute(q)
user = result.scalar_one_or_none()
if user is None:
return None
raw_token = secrets.token_urlsafe(32)
token_hash = hash_token(raw_token)
settings = get_settings()
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.password_reset_expiry_hours)
reset_token = PasswordResetToken(
tenant_id=user.tenant_id,
user_id=user.id,
token_hash=token_hash,
expires_at=expires_at,
)
db.add(reset_token)
await db.flush()
return raw_token
async def create_expired_reset_token(self, db: AsyncSession, email: str) -> str | None:
"""Create an already-expired reset token for testing."""
import secrets
q = select(User).where(User.email == email)
result = await db.execute(q)
user = result.scalar_one_or_none()
if user is None:
return None
raw_token = secrets.token_urlsafe(32)
token_hash = hash_token(raw_token)
expires_at = datetime.now(timezone.utc) - timedelta(hours=1) # Already expired
reset_token = PasswordResetToken(
tenant_id=user.tenant_id,
user_id=user.id,
token_hash=token_hash,
expires_at=expires_at,
)
db.add(reset_token)
await db.flush()
return raw_token
def build_token_response(user: User) -> str:
"""Build a fresh access token for a user."""
settings = get_settings()
_ = settings # touch to ensure config is loaded
return create_access_token(user.id, user.org_id, user.role.value)
auth_service = AuthService()
-128
View File
@@ -1,128 +0,0 @@
"""Contact service: create, get, list (with filters), update, soft-delete."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.account import Account
from app.models.contact import Contact
from app.schemas.contact import ContactCreate, ContactUpdate
from app.services._base import OrgScopedQuery
class ContactNotFound(Exception):
"""Raised when a contact lookup fails."""
class InvalidAccount(Exception):
"""Raised when account_id points to a non-existent account."""
async def _validate_account(db: AsyncSession, account_id: int, *, org_id: int) -> None:
"""Raise if account_id does not exist within the org."""
if account_id is None:
return
q = OrgScopedQuery(Account, db, org_id=org_id)
exists = await q.get(account_id)
if exists is None:
raise InvalidAccount(f"Account {account_id} not found in this org")
async def create_contact(
db: AsyncSession,
payload: ContactCreate,
*,
org_id: int,
owner_id: int,
) -> Contact:
"""Create a new contact. Validates account_id if provided."""
await _validate_account(db, payload.account_id, org_id=org_id)
contact = Contact(
org_id=org_id,
first_name=payload.first_name,
last_name=payload.last_name,
email=payload.email,
phone=payload.phone,
account_id=payload.account_id,
owner_id=owner_id,
)
db.add(contact)
try:
await db.commit()
except IntegrityError as e:
await db.rollback()
raise InvalidAccount(str(e)) from e
await db.refresh(contact)
return contact
async def get_contact(db: AsyncSession, contact_id: int, *, org_id: int) -> Contact | None:
"""Fetch a single contact by id."""
q = OrgScopedQuery(Contact, db, org_id=org_id)
return await q.get(contact_id)
async def list_contacts(
db: AsyncSession,
*,
org_id: int,
skip: int = 0,
limit: int = 20,
account_id: int | None = None,
owner_id: int | None = None,
q: str | None = None,
) -> list[Contact]:
"""List contacts with filters and search."""
scoped = OrgScopedQuery(Contact, db, org_id=org_id)
base = await scoped.list(
skip=skip,
limit=limit,
order_by=Contact.id,
account_id=account_id,
owner_id=owner_id,
)
if q:
needle = q.lower()
base = [
c
for c in base
if needle in c.first_name.lower()
or needle in c.last_name.lower()
or (c.email and needle in c.email.lower())
]
return base
async def update_contact(db: AsyncSession, contact: Contact, payload: ContactUpdate) -> Contact:
"""Apply partial updates to a contact."""
data = payload.model_dump(exclude_unset=True)
if "account_id" in data and data["account_id"] is not None:
await _validate_account(db, data["account_id"], org_id=contact.org_id)
for field, value in data.items():
if value is not None:
setattr(contact, field, value)
await db.commit()
await db.refresh(contact)
return contact
async def soft_delete_contact(db: AsyncSession, contact: Contact) -> Contact:
"""Soft-delete a contact."""
contact.deleted_at = datetime.now(UTC)
await db.commit()
await db.refresh(contact)
return contact
__all__ = [
"ContactNotFound",
"InvalidAccount",
"create_contact",
"get_contact",
"list_contacts",
"soft_delete_contact",
"update_contact",
]
-61
View File
@@ -1,61 +0,0 @@
"""Dashboard service: KPIs and activity feed."""
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.activity import Activity
from app.models.deal import Deal, DealStage
from app.services._base import OrgScopedQuery
async def get_kpis(db: AsyncSession, *, org_id: int) -> dict[str, object]:
"""Compute headline KPIs for the org's dashboard."""
scoped = OrgScopedQuery(Deal, db, org_id=org_id)
open_stages = (DealStage.lead, DealStage.qualified, DealStage.proposal, DealStage.negotiation)
all_deals = await scoped.list(skip=0, limit=10_000, order_by=Deal.id)
open_deals = [d for d in all_deals if d.stage in open_stages]
open_deals_count = len(open_deals)
pipeline_value = float(sum((d.value for d in open_deals), Decimal("0")))
now = datetime.now(UTC)
# SQLite returns naive datetimes; strip tz for comparison
month_start = now.replace(tzinfo=None, day=1, hour=0, minute=0, second=0, microsecond=0)
won_this_month = [
d for d in all_deals if d.stage == DealStage.won and d.created_at >= month_start
]
won_count = len(won_this_month)
won_total = sum(1 for d in all_deals if d.stage == DealStage.won)
lost_total = sum(1 for d in all_deals if d.stage == DealStage.lost)
if won_total + lost_total == 0:
conversion_rate = 0.0
else:
conversion_rate = round((won_total / (won_total + lost_total)) * 100, 2)
return {
"open_deals_count": open_deals_count,
"pipeline_value": pipeline_value,
"won_this_month": won_count,
"conversion_rate": conversion_rate,
}
async def get_activity_feed(db: AsyncSession, *, org_id: int, limit: int = 20) -> list[Activity]:
"""Return the most recent activities, ordered by created_at desc."""
result = await db.execute(
select(Activity)
.where(Activity.org_id == org_id, Activity.deleted_at.is_(None))
.order_by(Activity.created_at.desc())
.limit(limit)
)
return list(result.scalars().all())
__all__ = ["get_activity_feed", "get_kpis"]
-186
View File
@@ -1,186 +0,0 @@
"""Deal service: create, get, list, update, update_stage, get_pipeline, soft-delete."""
from __future__ import annotations
from collections import defaultdict
from datetime import UTC, datetime
from decimal import Decimal
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.account import Account
from app.models.deal import Deal, DealStage
from app.models.deal_stage_history import DealStageHistory
from app.schemas.deal import DealCreate, DealUpdate
from app.services._base import OrgScopedQuery
class DealNotFound(Exception):
"""Raised when a deal lookup fails."""
class InvalidAccount(Exception):
"""Raised when account_id points to a non-existent account."""
def _stage_value(stage: DealStage | str) -> str:
"""Normalize stage to its string value."""
if hasattr(stage, "value"):
return stage.value # type: ignore[union-attr]
return str(stage)
async def create_deal(
db: AsyncSession,
payload: DealCreate,
*,
org_id: int,
owner_id: int,
) -> Deal:
"""Create a new deal. Validates account_id exists in org."""
acc_q = OrgScopedQuery(Account, db, org_id=org_id)
if await acc_q.get(payload.account_id) is None:
raise InvalidAccount(f"Account {payload.account_id} not found in this org")
deal = Deal(
org_id=org_id,
title=payload.title,
value=payload.value,
currency=payload.currency,
stage=payload.stage,
close_date=payload.close_date,
account_id=payload.account_id,
owner_id=owner_id,
won_lost_reason=payload.won_lost_reason,
)
db.add(deal)
try:
await db.commit()
except IntegrityError as e:
await db.rollback()
raise InvalidAccount(str(e)) from e
await db.refresh(deal)
# Initial stage history entry (from None → initial stage)
history = DealStageHistory(
org_id=org_id,
deal_id=deal.id,
from_stage=None,
to_stage=deal.stage,
changed_by=owner_id,
)
db.add(history)
await db.commit()
await db.refresh(deal)
return deal
async def get_deal(db: AsyncSession, deal_id: int, *, org_id: int) -> Deal | None:
"""Fetch a single deal by id."""
q = OrgScopedQuery(Deal, db, org_id=org_id)
return await q.get(deal_id)
async def list_deals(
db: AsyncSession,
*,
org_id: int,
skip: int = 0,
limit: int = 20,
stage: str | None = None,
owner_id: int | None = None,
account_id: int | None = None,
) -> list[Deal]:
"""List deals with optional filters."""
scoped = OrgScopedQuery(Deal, db, org_id=org_id)
return await scoped.list(
skip=skip,
limit=limit,
order_by=Deal.id,
stage=stage,
owner_id=owner_id,
account_id=account_id,
)
async def update_deal(db: AsyncSession, deal: Deal, payload: DealUpdate) -> Deal:
"""Apply partial updates to a deal. Does NOT change stage (use update_stage)."""
data = payload.model_dump(exclude_unset=True)
# Disallow direct stage changes via update endpoint
data.pop("stage", None)
for field, value in data.items():
if value is not None:
setattr(deal, field, value)
await db.commit()
await db.refresh(deal)
return deal
async def update_stage(
db: AsyncSession,
deal: Deal,
new_stage: DealStage,
*,
changed_by: int,
reason: str | None = None,
) -> Deal:
"""Change a deal's stage and append a DealStageHistory record."""
from_stage_str = _stage_value(deal.stage)
to_stage_str = _stage_value(new_stage)
if from_stage_str == to_stage_str:
return deal # no-op
deal.stage = new_stage # type: ignore[assignment]
if new_stage in (DealStage.won, DealStage.lost) and reason is not None:
deal.won_lost_reason = reason
history = DealStageHistory(
org_id=deal.org_id,
deal_id=deal.id,
from_stage=from_stage_str,
to_stage=to_stage_str,
changed_by=changed_by,
)
db.add(history)
await db.commit()
await db.refresh(deal)
return deal
async def get_pipeline(db: AsyncSession, *, org_id: int) -> list[dict[str, object]]:
"""Return deals grouped by stage for the pipeline view."""
scoped = OrgScopedQuery(Deal, db, org_id=org_id)
all_deals = await scoped.list(skip=0, limit=10_000, order_by=Deal.id)
grouped: dict[str, list[Deal]] = defaultdict(list)
for d in all_deals:
grouped[_stage_value(d.stage)].append(d)
return [
{
"stage": stage.value,
"count": len(deals),
"total_value": float(sum((d.value for d in deals), Decimal("0"))),
"deals": deals,
}
for stage in DealStage
for deals in [grouped.get(stage.value, [])]
]
async def soft_delete_deal(db: AsyncSession, deal: Deal) -> Deal:
"""Soft-delete a deal."""
deal.deleted_at = datetime.now(UTC)
await db.commit()
await db.refresh(deal)
return deal
__all__ = [
"DealNotFound",
"InvalidAccount",
"create_deal",
"get_deal",
"get_pipeline",
"list_deals",
"soft_delete_deal",
"update_deal",
"update_stage",
]
-135
View File
@@ -1,135 +0,0 @@
"""Note service: polymorphic notes attached to accounts/contacts/deals (R-2 validation)."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.account import Account
from app.models.contact import Contact
from app.models.deal import Deal
from app.models.note import Note, NoteParentType
from app.schemas.note import NoteCreate, NoteUpdate
from app.services._base import OrgScopedQuery
class NoteNotFound(Exception):
"""Raised when a note lookup fails."""
class InvalidParent(Exception):
"""Raised when note parent_type + parent_id don't point to an existing entity.
Mitigates R-2 (polymorphic validation): because parent_id has no DB-level FK,
the service layer must verify the parent exists before creating a note.
"""
async def _validate_parent(
db: AsyncSession,
*,
parent_type: NoteParentType,
parent_id: int,
org_id: int,
) -> None:
"""Verify the (parent_type, parent_id) tuple points to an existing entity in org."""
if parent_type == NoteParentType.account:
if await OrgScopedQuery(Account, db, org_id=org_id).get(parent_id) is None:
raise InvalidParent(f"Account {parent_id} not found in this org (R-2 validation)")
elif parent_type == NoteParentType.contact:
if await OrgScopedQuery(Contact, db, org_id=org_id).get(parent_id) is None:
raise InvalidParent(f"Contact {parent_id} not found in this org (R-2 validation)")
elif parent_type == NoteParentType.deal:
if await OrgScopedQuery(Deal, db, org_id=org_id).get(parent_id) is None:
raise InvalidParent(f"Deal {parent_id} not found in this org (R-2 validation)")
async def create_note(
db: AsyncSession,
payload: NoteCreate,
*,
org_id: int,
author_id: int,
) -> Note:
"""Create a new note. R-2: validates parent_type + parent_id exist."""
parent_type = (
payload.parent_type
if isinstance(payload.parent_type, NoteParentType)
else NoteParentType(payload.parent_type)
)
await _validate_parent(
db,
parent_type=parent_type,
parent_id=payload.parent_id,
org_id=org_id,
)
note = Note(
org_id=org_id,
body=payload.body,
author_id=author_id,
parent_type=parent_type,
parent_id=payload.parent_id,
)
db.add(note)
await db.commit()
await db.refresh(note)
return note
async def get_note(db: AsyncSession, note_id: int, *, org_id: int) -> Note | None:
"""Fetch a single note by id."""
q = OrgScopedQuery(Note, db, org_id=org_id)
return await q.get(note_id)
async def list_notes(
db: AsyncSession,
*,
org_id: int,
skip: int = 0,
limit: int = 20,
parent_type: str | None = None,
parent_id: int | None = None,
) -> list[Note]:
"""List notes with optional parent filters."""
scoped = OrgScopedQuery(Note, db, org_id=org_id)
return await scoped.list(
skip=skip,
limit=limit,
order_by=Note.id,
parent_type=parent_type,
parent_id=parent_id,
)
async def update_note(db: AsyncSession, note: Note, payload: NoteUpdate) -> Note:
"""Apply partial updates to a note. Does NOT change parent (notes are pinned)."""
data = payload.model_dump(exclude_unset=True)
data.pop("parent_type", None)
data.pop("parent_id", None)
for field, value in data.items():
if value is not None:
setattr(note, field, value)
await db.commit()
await db.refresh(note)
return note
async def soft_delete_note(db: AsyncSession, note: Note) -> Note:
"""Soft-delete a note."""
note.deleted_at = datetime.now(UTC)
await db.commit()
await db.refresh(note)
return note
__all__ = [
"InvalidParent",
"NoteNotFound",
"create_note",
"get_note",
"list_notes",
"soft_delete_note",
"update_note",
]
+100
View File
@@ -0,0 +1,100 @@
"""Role management service."""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.role import Role
class RoleService:
"""Handles role CRUD operations."""
async def list_roles(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
) -> list[dict[str, Any]]:
"""List all roles in a tenant."""
q = select(Role).where(Role.tenant_id == tenant_id)
result = await db.execute(q)
roles = result.scalars().all()
return [self._role_to_dict(r) for r in roles]
async def create_role(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
name: str,
permissions: dict[str, Any],
field_permissions: dict[str, Any] | None = None,
) -> Role:
"""Create a new custom role."""
role = Role(
tenant_id=tenant_id,
name=name,
permissions=permissions,
field_permissions=field_permissions or {},
)
db.add(role)
await db.flush()
return role
async def update_role(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
role_id: uuid.UUID,
name: str | None = None,
permissions: dict[str, Any] | None = None,
field_permissions: dict[str, Any] | None = None,
) -> Role | None:
"""Update a role."""
q = select(Role).where(Role.id == role_id, Role.tenant_id == tenant_id)
result = await db.execute(q)
role = result.scalar_one_or_none()
if role is None:
return None
if name is not None:
role.name = name
if permissions is not None:
role.permissions = permissions
if field_permissions is not None:
role.field_permissions = field_permissions
await db.flush()
return role
async def delete_role(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
role_id: uuid.UUID,
) -> bool:
"""Delete a role."""
q = select(Role).where(Role.id == role_id, Role.tenant_id == tenant_id)
result = await db.execute(q)
role = result.scalar_one_or_none()
if role is None:
return False
await db.delete(role)
await db.flush()
return True
def _role_to_dict(self, role: Role) -> dict[str, Any]:
"""Convert role to response dict."""
return {
"id": str(role.id),
"name": role.name,
"permissions": role.permissions,
"field_permissions": role.field_permissions,
}
role_service = RoleService()
-147
View File
@@ -1,147 +0,0 @@
"""Tag service: tags and polymorphic tag_links (R-2 validation)."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.account import Account
from app.models.contact import Contact
from app.models.deal import Deal
from app.models.tag import Tag
from app.models.tag_link import TagLink, TagLinkParentType
from app.schemas.tag import TagCreate, TagLinkCreate
from app.services._base import OrgScopedQuery
class TagNotFound(Exception):
"""Raised when a tag lookup fails."""
class InvalidParent(Exception):
"""Raised when tag_link parent_type + parent_id don't point to an existing entity."""
class DuplicateTagLink(Exception):
"""Raised when a tag is already linked to a parent (unique constraint)."""
async def _validate_parent(
db: AsyncSession,
*,
parent_type: TagLinkParentType,
parent_id: int,
org_id: int,
) -> None:
"""Verify (parent_type, parent_id) points to an existing entity in org."""
if parent_type == TagLinkParentType.account:
if await OrgScopedQuery(Account, db, org_id=org_id).get(parent_id) is None:
raise InvalidParent(f"Account {parent_id} not found in this org (R-2 validation)")
elif parent_type == TagLinkParentType.contact:
if await OrgScopedQuery(Contact, db, org_id=org_id).get(parent_id) is None:
raise InvalidParent(f"Contact {parent_id} not found in this org (R-2 validation)")
elif parent_type == TagLinkParentType.deal:
if await OrgScopedQuery(Deal, db, org_id=org_id).get(parent_id) is None:
raise InvalidParent(f"Deal {parent_id} not found in this org (R-2 validation)")
async def create_tag(db: AsyncSession, payload: TagCreate, *, org_id: int) -> Tag:
"""Create a new tag in the given org."""
tag = Tag(
org_id=org_id,
name=payload.name,
color=payload.color,
)
db.add(tag)
await db.commit()
await db.refresh(tag)
return tag
async def list_tags(db: AsyncSession, *, org_id: int) -> list[Tag]:
"""List all tags in the org."""
scoped = OrgScopedQuery(Tag, db, org_id=org_id)
return await scoped.list(skip=0, limit=1000, order_by=Tag.id)
async def get_tag(db: AsyncSession, tag_id: int, *, org_id: int) -> Tag | None:
"""Fetch a single tag by id."""
return await OrgScopedQuery(Tag, db, org_id=org_id).get(tag_id)
async def link_tag(db: AsyncSession, payload: TagLinkCreate, *, org_id: int) -> TagLink:
"""Link a tag to an entity. R-2: validates parent exists."""
parent_type = (
payload.parent_type
if isinstance(payload.parent_type, TagLinkParentType)
else TagLinkParentType(payload.parent_type)
)
# Verify tag exists in this org
if await OrgScopedQuery(Tag, db, org_id=org_id).get(payload.tag_id) is None:
raise TagNotFound(f"Tag {payload.tag_id} not found in this org")
# Verify parent exists in this org (R-2)
await _validate_parent(
db,
parent_type=parent_type,
parent_id=payload.parent_id,
org_id=org_id,
)
link = TagLink(
org_id=org_id,
tag_id=payload.tag_id,
parent_type=parent_type,
parent_id=payload.parent_id,
)
db.add(link)
try:
await db.commit()
except IntegrityError as e:
await db.rollback()
raise DuplicateTagLink(
f"Tag {payload.tag_id} is already linked to {parent_type.value}:{payload.parent_id}"
) from e
await db.refresh(link)
return link
async def unlink_tag(
db: AsyncSession,
*,
tag_id: int,
parent_type: TagLinkParentType,
parent_id: int,
org_id: int,
) -> bool:
"""Unlink a tag from an entity. Returns True if a link was deleted."""
from sqlalchemy import select
parent_type_str = parent_type.value if hasattr(parent_type, "value") else str(parent_type)
result = await db.execute(
select(TagLink).where(
TagLink.org_id == org_id,
TagLink.tag_id == tag_id,
TagLink.parent_type == parent_type_str,
TagLink.parent_id == parent_id,
TagLink.deleted_at.is_(None),
)
)
link = result.scalar_one_or_none()
if link is None:
return False
link.deleted_at = datetime.now(UTC)
await db.commit()
return True
__all__ = [
"DuplicateTagLink",
"InvalidParent",
"TagNotFound",
"create_tag",
"get_tag",
"link_tag",
"list_tags",
"unlink_tag",
]
+96
View File
@@ -0,0 +1,96 @@
"""Tenant management service."""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
class TenantService:
"""Handles tenant CRUD and user-tenant membership."""
async def list_tenants_for_user(
self,
db: AsyncSession,
user_id: uuid.UUID,
) -> list[dict[str, Any]]:
"""List all tenants a user belongs to."""
q = (
select(Tenant, UserTenant.is_default)
.join(UserTenant, UserTenant.tenant_id == Tenant.id)
.where(UserTenant.user_id == user_id)
)
result = await db.execute(q)
rows = result.all()
return [
{
"id": str(t.id),
"name": t.name,
"slug": t.slug,
"is_default": is_default,
}
for t, is_default in rows
]
async def create_tenant(
self,
db: AsyncSession,
name: str,
slug: str,
) -> Tenant:
"""Create a new tenant."""
tenant = Tenant(name=name, slug=slug)
db.add(tenant)
await db.flush()
return tenant
async def get_tenant(self, db: AsyncSession, tenant_id: uuid.UUID) -> Tenant | None:
"""Get a tenant by ID."""
q = select(Tenant).where(Tenant.id == tenant_id)
result = await db.execute(q)
return result.scalar_one_or_none()
async def list_tenant_users(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
) -> list[dict[str, Any]]:
"""List users in a tenant."""
q = select(User).where(User.tenant_id == tenant_id)
result = await db.execute(q)
users = result.scalars().all()
return [
{
"id": str(u.id),
"email": u.email,
"name": u.name,
"role": u.role,
"is_active": u.is_active,
}
for u in users
]
async def assign_user_to_tenant(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> UserTenant:
"""Assign a user to a tenant."""
ut = UserTenant(
user_id=user_id,
tenant_id=tenant_id,
is_default=False,
)
db.add(ut)
await db.flush()
return ut
tenant_service = TenantService()
+143 -99
View File
@@ -1,109 +1,153 @@
"""User service: read, update, soft-delete, list."""
"""User management service."""
from __future__ import annotations
from datetime import UTC, datetime
import uuid
from typing import Any
from sqlalchemy import func, select
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password
from app.models.user import User, UserRole
from app.schemas.user import UserCreateRequest, UserUpdate
from app.core.auth import hash_password
from app.models.user import User, UserTenant
class UserNotFound(Exception):
"""Raised when a user lookup fails."""
class UserService:
"""Handles user CRUD operations."""
async def list_users(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
page: int = 1,
page_size: int = 25,
search: str | None = None,
) -> dict[str, Any]:
"""List users in a tenant with pagination and search."""
offset = (page - 1) * page_size
q = select(User).where(User.tenant_id == tenant_id)
count_q = select(func.count()).select_from(User).where(User.tenant_id == tenant_id)
if search:
search_filter = or_(
User.name.ilike(f"%{search}%"),
User.email.ilike(f"%{search}%"),
)
q = q.where(search_filter)
count_q = count_q.where(search_filter)
total = (await db.execute(count_q)).scalar() or 0
q = q.offset(offset).limit(page_size).order_by(User.created_at.desc())
result = await db.execute(q)
users = result.scalars().all()
return {
"items": [self._user_to_dict(u) for u in users],
"total": total,
"page": page,
"page_size": page_size,
}
async def get_user(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> User | None:
"""Get a single user by ID within tenant scope."""
q = select(User).where(User.id == user_id, User.tenant_id == tenant_id)
result = await db.execute(q)
return result.scalar_one_or_none()
async def create_user(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
email: str,
name: str,
password: str,
role: str = "viewer",
is_active: bool = True,
) -> User:
"""Create a new user in a tenant."""
user = User(
tenant_id=tenant_id,
email=email,
name=name,
password_hash=hash_password(password),
role=role,
is_active=is_active,
preferences={},
)
db.add(user)
await db.flush()
# Add user-tenant membership
ut = UserTenant(
user_id=user.id,
tenant_id=tenant_id,
is_default=True,
)
db.add(ut)
await db.flush()
return user
async def update_user(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
name: str | None = None,
role: str | None = None,
is_active: bool | None = None,
) -> User | None:
"""Update a user."""
q = select(User).where(User.id == user_id, User.tenant_id == tenant_id)
result = await db.execute(q)
user = result.scalar_one_or_none()
if user is None:
return None
if name is not None:
user.name = name
if role is not None:
user.role = role
if is_active is not None:
user.is_active = is_active
await db.flush()
return user
async def delete_user(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> bool:
"""Delete a user from a tenant."""
q = select(User).where(User.id == user_id, User.tenant_id == tenant_id)
result = await db.execute(q)
user = result.scalar_one_or_none()
if user is None:
return False
await db.delete(user)
await db.flush()
return True
def _user_to_dict(self, user: User) -> dict[str, Any]:
"""Convert user to response dict."""
return {
"id": str(user.id),
"email": user.email,
"name": user.name,
"role": user.role,
"is_active": user.is_active,
"tenant_id": str(user.tenant_id),
}
class EmailAlreadyTaken(Exception):
"""Raised when attempting to create/update a user with an existing email."""
async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
"""Fetch a user by ID (active only, soft-deleted excluded)."""
result = await db.execute(select(User).where(User.id == user_id, User.deleted_at.is_(None)))
return result.scalar_one_or_none()
async def get_user_by_email(db: AsyncSession, email: str, org_id: int | None = None) -> User | None:
"""Fetch a user by email, optionally scoped to an org."""
stmt = select(User).where(
User.email == email.lower(),
User.deleted_at.is_(None),
)
if org_id is not None:
stmt = stmt.where(User.org_id == org_id)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def update_user_profile(
db: AsyncSession, user: User, payload: UserUpdate, *, is_admin: bool = False
) -> User:
"""Apply partial updates to a user.
Non-admin callers cannot change the role. All other fields are optional.
"""
data = payload.model_dump(exclude_unset=True)
if "role" in data and not is_admin:
# Silently drop role change for non-admin callers
data.pop("role")
for field, value in data.items():
if value is not None:
setattr(user, field, value)
await db.commit()
await db.refresh(user)
return user
async def soft_delete_user(db: AsyncSession, user: User) -> User:
"""Soft-delete a user by setting deleted_at to now."""
user.deleted_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
return user
async def create_user_as_admin(db: AsyncSession, payload: UserCreateRequest, org_id: int) -> User:
"""Create a new user in the given org (admin-only flow)."""
existing = await get_user_by_email(db, payload.email, org_id=org_id)
if existing is not None:
raise EmailAlreadyTaken(f"A user with email {payload.email!r} already exists in this org.")
user = User(
org_id=org_id,
email=payload.email.lower(),
password_hash=hash_password(payload.password),
name=payload.name,
role=payload.role if payload.role else UserRole.sales_rep,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
async def list_users(db: AsyncSession, org_id: int, skip: int = 0, limit: int = 50) -> list[User]:
"""List active users in an org, paginated."""
result = await db.execute(
select(User)
.where(User.org_id == org_id, User.deleted_at.is_(None))
.order_by(User.id)
.offset(skip)
.limit(limit)
)
return list(result.scalars().all())
async def count_users_in_org(db: AsyncSession, org_id: int) -> int:
"""Count active users in an org."""
result = await db.execute(
select(func.count())
.select_from(User)
.where(User.org_id == org_id, User.deleted_at.is_(None))
)
return int(result.scalar_one())
user_service = UserService()