feat(phase-4b): backend business-logic, 8 entities, 32 endpoints, 58 tests

This commit is contained in:
CRM Bot
2026-06-03 21:40:26 +00:00
parent 955607f730
commit 53cbcde729
45 changed files with 3902 additions and 4 deletions
+8 -1
View File
@@ -1 +1,8 @@
"""Business logic service layer."""
"""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] = []
+79
View File
@@ -0,0 +1,79 @@
"""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, Optional
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: Optional[Any] = 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"]
+107
View File
@@ -0,0 +1,107 @@
"""Account service: create, get, list (with filters), update, soft-delete."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Optional
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."""
from app.services._base import OrgScopedQuery
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
) -> Optional[Account]:
"""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: Optional[str] = None,
size: Optional[str] = None,
owner_id: Optional[int] = None,
q: Optional[str] = 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",
]
+184
View File
@@ -0,0 +1,184 @@
"""Activity service: create, get, list, update, complete, soft-delete."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Optional
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: Optional[int],
contact_id: Optional[int],
deal_id: Optional[int],
) -> 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
) -> Optional[Activity]:
"""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: Optional[ActivityType] = None,
owner_id: Optional[int] = None,
overdue: Optional[bool] = None,
completed: Optional[bool] = 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",
]
+135
View File
@@ -0,0 +1,135 @@
"""Contact service: create, get, list (with filters), update, soft-delete."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Optional
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
) -> Optional[Contact]:
"""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: Optional[int] = None,
owner_id: Optional[int] = None,
q: Optional[str] = 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",
]
+66
View File
@@ -0,0 +1,66 @@
"""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"]
+193
View File
@@ -0,0 +1,193 @@
"""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 typing import Optional
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
) -> Optional[Deal]:
"""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: Optional[str] = None,
owner_id: Optional[int] = None,
account_id: Optional[int] = 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: Optional[str] = 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",
]
+146
View File
@@ -0,0 +1,146 @@
"""Note service: polymorphic notes attached to accounts/contacts/deals (R-2 validation)."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Optional
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
) -> Optional[Note]:
"""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: Optional[str] = None,
parent_id: Optional[int] = 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",
]
+158
View File
@@ -0,0 +1,158 @@
"""Tag service: tags and polymorphic tag_links (R-2 validation)."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Optional
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) -> Optional[Tag]:
"""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",
]