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).
110 lines
4.2 KiB
Python
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()
|