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,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",
|
||||
]
|
||||
Reference in New Issue
Block a user