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:
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user