Files
CIAgent/apps/api/app/services/notification_destination_service.py
saksham 1a4c80958f Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring,
source collection, LLM-based change analysis, enrichment, and account
security (Turnstile, escalating lockout, email verification).
2026-08-05 10:48:20 -04:00

110 lines
4.2 KiB
Python

from __future__ import annotations
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import NotFoundError, ValidationAppError
from app.models.notification_destination import NotificationDestination
from app.repositories.company_repository import CompanyRepository
from app.repositories.notification_destination_repository import (
NotificationDestinationRepository,
)
from app.schemas.notification_destination import (
NotificationDestinationCreate,
NotificationDestinationUpdate,
)
async def list_destinations(db: AsyncSession, user_id: uuid.UUID) -> list[NotificationDestination]:
repo = NotificationDestinationRepository(db)
return await repo.list_for_user(user_id)
async def get_destination(
db: AsyncSession, user_id: uuid.UUID, destination_id: uuid.UUID
) -> NotificationDestination:
repo = NotificationDestinationRepository(db)
destination = await repo.get_for_user(destination_id, user_id)
if destination is None:
raise NotFoundError("Notification destination not found")
return destination
async def create_destination(
db: AsyncSession, user_id: uuid.UUID, payload: NotificationDestinationCreate
) -> NotificationDestination:
"""Reuses an existing destination for the same (user, type, value)
rather than creating a duplicate row - this is the fix for the wizard
previously creating a fresh row per company even when the email/phone
was already registered. Either way, the result ends up linked to every
company in payload.company_ids."""
company_repo = CompanyRepository(db)
for company_id in payload.company_ids:
if await company_repo.get_for_user(company_id, user_id) is None:
raise ValidationAppError(f"Company {company_id} not found")
repo = NotificationDestinationRepository(db)
destination = await repo.find_by_value(user_id, payload.type, payload.destination_value)
if destination is None:
destination = await repo.create(
user_id=user_id,
type=payload.type,
destination_value=payload.destination_value,
minimum_severity=payload.minimum_severity,
enabled=payload.enabled,
)
for company_id in payload.company_ids:
await repo.link_company(destination.id, company_id)
await db.commit()
refreshed = await repo.get_for_user(destination.id, user_id)
assert refreshed is not None
return refreshed
async def update_destination(
db: AsyncSession,
user_id: uuid.UUID,
destination_id: uuid.UUID,
payload: NotificationDestinationUpdate,
) -> NotificationDestination:
destination = await get_destination(db, user_id, destination_id)
updates = payload.model_dump(exclude_unset=True)
for field, value in updates.items():
setattr(destination, field, value)
if field == "destination_value":
# Changing the destination value invalidates any prior verification.
destination.verified = False
await db.commit()
# Re-fetch (rather than db.refresh) so company_links stays eager-loaded -
# refresh() would expire it, and a bare lazy-load isn't safe under
# SQLAlchemy's async ORM without an active await context.
refreshed = await get_destination(db, user_id, destination_id)
return refreshed
async def delete_destination(
db: AsyncSession, user_id: uuid.UUID, destination_id: uuid.UUID
) -> None:
repo = NotificationDestinationRepository(db)
destination = await get_destination(db, user_id, destination_id)
await repo.delete(destination)
await db.commit()
async def unlink_company(
db: AsyncSession, user_id: uuid.UUID, destination_id: uuid.UUID, company_id: uuid.UUID
) -> None:
"""Removes this one company's link, not the destination itself - a
destination can be shared across companies (see create_destination's
dedup-by-value). If this was its last remaining link, it's an orphan
now and delete_orphaned_for_user removes it outright, same as already
happens when a company itself is deleted."""
await get_destination(db, user_id, destination_id) # ownership check
repo = NotificationDestinationRepository(db)
await repo.unlink_company(destination_id, company_id)
await repo.delete_orphaned_for_user(user_id)
await db.commit()