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
+123
View File
@@ -0,0 +1,123 @@
"""Account API endpoints."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.deps import get_current_user
from app.models.account import AccountSize, Industry
from app.models.user import User
from app.schemas.account import AccountCreate, AccountOut, AccountUpdate
from app.services.account_service import (
create_account as svc_create_account,
get_account as svc_get_account,
list_accounts as svc_list_accounts,
soft_delete_account as svc_soft_delete_account,
update_account as svc_update_account,
)
router = APIRouter(prefix="/accounts", tags=["accounts"])
@router.post(
"/",
response_model=AccountOut,
status_code=status.HTTP_201_CREATED,
summary="Create a new account",
)
async def create_account(
payload: AccountCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> AccountOut:
acc = await svc_create_account(
db, payload, org_id=current_user.org_id, owner_id=current_user.id
)
return AccountOut.model_validate(acc)
@router.get(
"/",
response_model=list[AccountOut],
summary="List accounts (paginated, filterable)",
)
async def list_accounts(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
industry: Optional[Industry] = None,
size: Optional[AccountSize] = None,
owner_id: Optional[int] = None,
q: Optional[str] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[AccountOut]:
accounts = await svc_list_accounts(
db,
org_id=current_user.org_id,
skip=skip,
limit=limit,
industry=industry.value if industry else None,
size=size.value if size else None,
owner_id=owner_id,
q=q,
)
return [AccountOut.model_validate(a) for a in accounts]
@router.get(
"/{account_id}",
response_model=AccountOut,
summary="Get one account with all relations",
)
async def get_account(
account_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> AccountOut:
acc = await svc_get_account(db, account_id, org_id=current_user.org_id)
if acc is None:
raise HTTPException(status_code=404, detail="Account not found")
return AccountOut.model_validate(acc)
@router.patch(
"/{account_id}",
response_model=AccountOut,
summary="Update an account",
)
async def update_account(
account_id: int,
payload: AccountUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> AccountOut:
acc = await svc_get_account(db, account_id, org_id=current_user.org_id)
if acc is None:
raise HTTPException(status_code=404, detail="Account not found")
updated = await svc_update_account(db, acc, payload)
return AccountOut.model_validate(updated)
@router.delete(
"/{account_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_class=Response,
summary="Soft-delete an account",
)
async def delete_account(
account_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Response:
acc = await svc_get_account(db, account_id, org_id=current_user.org_id)
if acc is None:
raise HTTPException(status_code=404, detail="Account not found")
await svc_soft_delete_account(db, acc)
return Response(status_code=status.HTTP_204_NO_CONTENT)
__all__ = ["router"]
+162
View File
@@ -0,0 +1,162 @@
"""Activity API endpoints."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.deps import get_current_user
from app.models.activity import ActivityType
from app.models.user import User
from app.schemas.activity import (
ActivityCompleteRequest,
ActivityCreate,
ActivityOut,
ActivityUpdate,
)
from app.services.activity_service import (
NoParentException,
complete_activity as svc_complete_activity,
create_activity as svc_create_activity,
get_activity as svc_get_activity,
list_activities as svc_list_activities,
soft_delete_activity as svc_soft_delete_activity,
update_activity as svc_update_activity,
)
router = APIRouter(prefix="/activities", tags=["activities"])
@router.post(
"/",
response_model=ActivityOut,
status_code=status.HTTP_201_CREATED,
summary="Create a new activity (polymorphic parent: account/contact/deal)",
)
async def create_activity(
payload: ActivityCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> ActivityOut:
try:
activity = await svc_create_activity(
db, payload, org_id=current_user.org_id, owner_id=current_user.id
)
except NoParentException as e:
raise HTTPException(status_code=422, detail=str(e)) from e
return ActivityOut.model_validate(activity)
@router.get(
"/",
response_model=list[ActivityOut],
summary="List activities (paginated, filterable)",
)
async def list_activities(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
type: Optional[ActivityType] = None,
owner_id: Optional[int] = None,
overdue: Optional[bool] = None,
completed: Optional[bool] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[ActivityOut]:
activities = await svc_list_activities(
db,
org_id=current_user.org_id,
skip=skip,
limit=limit,
type=type,
owner_id=owner_id,
overdue=overdue,
completed=completed,
)
return [ActivityOut.model_validate(a) for a in activities]
@router.get(
"/{activity_id}",
response_model=ActivityOut,
summary="Get one activity",
)
async def get_activity(
activity_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> ActivityOut:
activity = await svc_get_activity(
db, activity_id, org_id=current_user.org_id
)
if activity is None:
raise HTTPException(status_code=404, detail="Activity not found")
return ActivityOut.model_validate(activity)
@router.patch(
"/{activity_id}",
response_model=ActivityOut,
summary="Update an activity",
)
async def update_activity(
activity_id: int,
payload: ActivityUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> ActivityOut:
activity = await svc_get_activity(
db, activity_id, org_id=current_user.org_id
)
if activity is None:
raise HTTPException(status_code=404, detail="Activity not found")
try:
updated = await svc_update_activity(db, activity, payload)
except NoParentException as e:
raise HTTPException(status_code=422, detail=str(e)) from e
return ActivityOut.model_validate(updated)
@router.patch(
"/{activity_id}/complete",
response_model=ActivityOut,
summary="Mark an activity as completed",
)
async def complete_activity(
activity_id: int,
payload: ActivityCompleteRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> ActivityOut:
activity = await svc_get_activity(
db, activity_id, org_id=current_user.org_id
)
if activity is None:
raise HTTPException(status_code=404, detail="Activity not found")
updated = await svc_complete_activity(db, activity, payload)
return ActivityOut.model_validate(updated)
@router.delete(
"/{activity_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_class=Response,
summary="Soft-delete an activity",
)
async def delete_activity(
activity_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Response:
activity = await svc_get_activity(
db, activity_id, org_id=current_user.org_id
)
if activity is None:
raise HTTPException(status_code=404, detail="Activity not found")
await svc_soft_delete_activity(db, activity)
return Response(status_code=status.HTTP_204_NO_CONTENT)
__all__ = ["router"]
+127
View File
@@ -0,0 +1,127 @@
"""Contact API endpoints."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.schemas.contact import ContactCreate, ContactOut, ContactUpdate
from app.services.contact_service import (
InvalidAccount,
create_contact as svc_create_contact,
get_contact as svc_get_contact,
list_contacts as svc_list_contacts,
soft_delete_contact as svc_soft_delete_contact,
update_contact as svc_update_contact,
)
router = APIRouter(prefix="/contacts", tags=["contacts"])
@router.post(
"/",
response_model=ContactOut,
status_code=status.HTTP_201_CREATED,
summary="Create a new contact",
)
async def create_contact(
payload: ContactCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> ContactOut:
try:
contact = await svc_create_contact(
db, payload, org_id=current_user.org_id, owner_id=current_user.id
)
except InvalidAccount as e:
raise HTTPException(status_code=404, detail=str(e)) from e
return ContactOut.model_validate(contact)
@router.get(
"/",
response_model=list[ContactOut],
summary="List contacts (paginated, filterable)",
)
async def list_contacts(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
account_id: Optional[int] = None,
owner_id: Optional[int] = None,
q: Optional[str] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[ContactOut]:
contacts = await svc_list_contacts(
db,
org_id=current_user.org_id,
skip=skip,
limit=limit,
account_id=account_id,
owner_id=owner_id,
q=q,
)
return [ContactOut.model_validate(c) for c in contacts]
@router.get(
"/{contact_id}",
response_model=ContactOut,
summary="Get one contact",
)
async def get_contact(
contact_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> ContactOut:
contact = await svc_get_contact(db, contact_id, org_id=current_user.org_id)
if contact is None:
raise HTTPException(status_code=404, detail="Contact not found")
return ContactOut.model_validate(contact)
@router.patch(
"/{contact_id}",
response_model=ContactOut,
summary="Update a contact",
)
async def update_contact(
contact_id: int,
payload: ContactUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> ContactOut:
contact = await svc_get_contact(db, contact_id, org_id=current_user.org_id)
if contact is None:
raise HTTPException(status_code=404, detail="Contact not found")
try:
updated = await svc_update_contact(db, contact, payload)
except InvalidAccount as e:
raise HTTPException(status_code=404, detail=str(e)) from e
return ContactOut.model_validate(updated)
@router.delete(
"/{contact_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_class=Response,
summary="Soft-delete a contact",
)
async def delete_contact(
contact_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Response:
contact = await svc_get_contact(db, contact_id, org_id=current_user.org_id)
if contact is None:
raise HTTPException(status_code=404, detail="Contact not found")
await svc_soft_delete_contact(db, contact)
return Response(status_code=status.HTTP_204_NO_CONTENT)
__all__ = ["router"]
+49
View File
@@ -0,0 +1,49 @@
"""Dashboard API endpoints: KPIs and activity feed."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.schemas.dashboard import ActivityFeedItem, KPIOut
from app.services.dashboard_service import (
get_activity_feed,
get_kpis,
)
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get(
"/kpis",
response_model=KPIOut,
summary="Get dashboard KPIs (open deals, pipeline value, won this month, conversion rate)",
)
async def get_kpis_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> KPIOut:
kpis = await get_kpis(db, org_id=current_user.org_id)
return KPIOut(**kpis) # type: ignore[arg-type]
@router.get(
"/feed",
response_model=list[ActivityFeedItem],
summary="Get the latest 20 activities (sorted by created_at desc)",
)
async def get_activity_feed_endpoint(
limit: int = 20,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[ActivityFeedItem]:
activities = await get_activity_feed(
db, org_id=current_user.org_id, limit=limit
)
return [ActivityFeedItem.model_validate(a) for a in activities]
__all__ = ["router"]
+175
View File
@@ -0,0 +1,175 @@
"""Deal API endpoints."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.deps import get_current_user
from app.models.deal import DealStage
from app.models.user import User
from app.schemas.deal import (
DealCreate,
DealOut,
DealPipelineOut,
DealStageUpdate,
DealUpdate,
)
from app.services.deal_service import (
InvalidAccount,
create_deal as svc_create_deal,
get_deal as svc_get_deal,
get_pipeline,
list_deals as svc_list_deals,
soft_delete_deal as svc_soft_delete_deal,
update_deal as svc_update_deal,
update_stage as svc_update_stage,
)
router = APIRouter(prefix="/deals", tags=["deals"])
@router.post(
"/",
response_model=DealOut,
status_code=status.HTTP_201_CREATED,
summary="Create a new deal",
)
async def create_deal(
payload: DealCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> DealOut:
try:
deal = await svc_create_deal(
db, payload, org_id=current_user.org_id, owner_id=current_user.id
)
except InvalidAccount as e:
raise HTTPException(status_code=404, detail=str(e)) from e
return DealOut.model_validate(deal)
@router.get(
"/",
response_model=list[DealOut],
summary="List deals (paginated, filterable)",
)
async def list_deals(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
stage: Optional[DealStage] = None,
owner_id: Optional[int] = None,
account_id: Optional[int] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[DealOut]:
deals = await svc_list_deals(
db,
org_id=current_user.org_id,
skip=skip,
limit=limit,
stage=stage.value if stage else None,
owner_id=owner_id,
account_id=account_id,
)
return [DealOut.model_validate(d) for d in deals]
@router.get(
"/pipeline",
response_model=list[DealPipelineOut],
summary="Get pipeline view (deals grouped by stage)",
)
async def get_pipeline_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[DealPipelineOut]:
pipeline = await get_pipeline(db, org_id=current_user.org_id)
out: list[DealPipelineOut] = []
for item in pipeline:
out.append(
DealPipelineOut(
stage=DealStage(item["stage"]),
count=int(item["count"]),
total_value=float(item["total_value"]),
)
)
return out
@router.get(
"/{deal_id}",
response_model=DealOut,
summary="Get one deal",
)
async def get_deal(
deal_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> DealOut:
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
if deal is None:
raise HTTPException(status_code=404, detail="Deal not found")
return DealOut.model_validate(deal)
@router.patch(
"/{deal_id}",
response_model=DealOut,
summary="Update a deal (does NOT change stage)",
)
async def update_deal(
deal_id: int,
payload: DealUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> DealOut:
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
if deal is None:
raise HTTPException(status_code=404, detail="Deal not found")
updated = await svc_update_deal(db, deal, payload)
return DealOut.model_validate(updated)
@router.patch(
"/{deal_id}/stage",
response_model=DealOut,
summary="Change a deal's stage (creates DealStageHistory entry)",
)
async def update_deal_stage(
deal_id: int,
payload: DealStageUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> DealOut:
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
if deal is None:
raise HTTPException(status_code=404, detail="Deal not found")
updated = await svc_update_stage(
db, deal, payload.stage, changed_by=current_user.id, reason=payload.reason
)
return DealOut.model_validate(updated)
@router.delete(
"/{deal_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_class=Response,
summary="Soft-delete a deal",
)
async def delete_deal(
deal_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Response:
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
if deal is None:
raise HTTPException(status_code=404, detail="Deal not found")
await svc_soft_delete_deal(db, deal)
return Response(status_code=status.HTTP_204_NO_CONTENT)
__all__ = ["router"]
+123
View File
@@ -0,0 +1,123 @@
"""Note API endpoints (R-2: polymorphic parent validation)."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.deps import get_current_user
from app.models.note import NoteParentType
from app.models.user import User
from app.schemas.note import NoteCreate, NoteOut, NoteUpdate
from app.services.note_service import (
InvalidParent,
create_note as svc_create_note,
get_note as svc_get_note,
list_notes as svc_list_notes,
soft_delete_note as svc_soft_delete_note,
update_note as svc_update_note,
)
router = APIRouter(prefix="/notes", tags=["notes"])
@router.post(
"/",
response_model=NoteOut,
status_code=status.HTTP_201_CREATED,
summary="Create a new note (R-2: validates parent exists)",
)
async def create_note(
payload: NoteCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> NoteOut:
try:
note = await svc_create_note(
db, payload, org_id=current_user.org_id, author_id=current_user.id
)
except InvalidParent as e:
raise HTTPException(status_code=404, detail=str(e)) from e
return NoteOut.model_validate(note)
@router.get(
"/",
response_model=list[NoteOut],
summary="List notes (filterable by parent_type + parent_id)",
)
async def list_notes(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
parent_type: Optional[NoteParentType] = None,
parent_id: Optional[int] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[NoteOut]:
notes = await svc_list_notes(
db,
org_id=current_user.org_id,
skip=skip,
limit=limit,
parent_type=parent_type.value if parent_type else None,
parent_id=parent_id,
)
return [NoteOut.model_validate(n) for n in notes]
@router.get(
"/{note_id}",
response_model=NoteOut,
summary="Get one note",
)
async def get_note(
note_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> NoteOut:
note = await svc_get_note(db, note_id, org_id=current_user.org_id)
if note is None:
raise HTTPException(status_code=404, detail="Note not found")
return NoteOut.model_validate(note)
@router.patch(
"/{note_id}",
response_model=NoteOut,
summary="Update a note's body",
)
async def update_note(
note_id: int,
payload: NoteUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> NoteOut:
note = await svc_get_note(db, note_id, org_id=current_user.org_id)
if note is None:
raise HTTPException(status_code=404, detail="Note not found")
updated = await svc_update_note(db, note, payload)
return NoteOut.model_validate(updated)
@router.delete(
"/{note_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_class=Response,
summary="Soft-delete a note",
)
async def delete_note(
note_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Response:
note = await svc_get_note(db, note_id, org_id=current_user.org_id)
if note is None:
raise HTTPException(status_code=404, detail="Note not found")
await svc_soft_delete_note(db, note)
return Response(status_code=status.HTTP_204_NO_CONTENT)
__all__ = ["router"]
+98
View File
@@ -0,0 +1,98 @@
"""Tag API endpoints (R-2: polymorphic parent validation)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.schemas.tag import TagCreate, TagLinkCreate, TagLinkOut, TagOut
from app.services.tag_service import (
DuplicateTagLink,
InvalidParent,
TagNotFound,
create_tag as svc_create_tag,
link_tag as svc_link_tag,
list_tags as svc_list_tags,
unlink_tag as svc_unlink_tag,
)
router = APIRouter(prefix="/tags", tags=["tags"])
@router.post(
"/",
response_model=TagOut,
status_code=status.HTTP_201_CREATED,
summary="Create a new tag",
)
async def create_tag(
payload: TagCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> TagOut:
tag = await svc_create_tag(db, payload, org_id=current_user.org_id)
return TagOut.model_validate(tag)
@router.get(
"/",
response_model=list[TagOut],
summary="List all tags in the org",
)
async def list_tags(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[TagOut]:
tags = await svc_list_tags(db, org_id=current_user.org_id)
return [TagOut.model_validate(t) for t in tags]
@router.post(
"/link",
response_model=TagLinkOut,
status_code=status.HTTP_201_CREATED,
summary="Link a tag to an entity (R-2: validates parent exists)",
)
async def link_tag_endpoint(
payload: TagLinkCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> TagLinkOut:
try:
link = await svc_link_tag(db, payload, org_id=current_user.org_id)
except InvalidParent as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except TagNotFound as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except DuplicateTagLink as e:
raise HTTPException(status_code=409, detail=str(e)) from e
return TagLinkOut.model_validate(link)
@router.delete(
"/link",
status_code=status.HTTP_204_NO_CONTENT,
response_class=Response,
summary="Unlink a tag from an entity",
)
async def unlink_tag_endpoint(
payload: TagLinkCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Response:
deleted = await svc_unlink_tag(
db,
tag_id=payload.tag_id,
parent_type=payload.parent_type,
parent_id=payload.parent_id,
org_id=current_user.org_id,
)
if not deleted:
raise HTTPException(status_code=404, detail="Tag link not found")
return Response(status_code=status.HTTP_204_NO_CONTENT)
__all__ = ["router"]