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).
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user