Files
CIAgent/apps/api/app/repositories/notification_destination_repository.py
T
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

150 lines
5.7 KiB
Python

from __future__ import annotations
import uuid
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.enums import NotificationType, SeverityLevel
from app.models.notification_destination import (
NotificationDestination,
NotificationDestinationCompany,
)
def _with_companies(stmt):
return stmt.options(
selectinload(NotificationDestination.company_links).selectinload(
NotificationDestinationCompany.company
)
)
class NotificationDestinationRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list_for_user(self, user_id: uuid.UUID) -> list[NotificationDestination]:
result = await self.db.execute(
_with_companies(
select(NotificationDestination)
.where(NotificationDestination.user_id == user_id)
.order_by(NotificationDestination.created_at.desc())
)
)
return list(result.scalars().all())
async def list_for_company(self, company_id: uuid.UUID) -> list[NotificationDestination]:
"""Only destinations actually linked to this company - what alert
dispatch notifies, as opposed to list_for_user's "everything this
user owns" (used by the Settings page)."""
result = await self.db.execute(
select(NotificationDestination)
.join(
NotificationDestinationCompany,
NotificationDestinationCompany.destination_id == NotificationDestination.id,
)
.where(NotificationDestinationCompany.company_id == company_id)
)
return list(result.scalars().all())
async def get_for_user(
self, destination_id: uuid.UUID, user_id: uuid.UUID
) -> NotificationDestination | None:
result = await self.db.execute(
_with_companies(
select(NotificationDestination).where(
NotificationDestination.id == destination_id,
NotificationDestination.user_id == user_id,
)
)
)
return result.scalar_one_or_none()
async def find_by_value(
self, user_id: uuid.UUID, type: NotificationType, destination_value: str
) -> NotificationDestination | None:
"""Case-insensitive for email (RFC-technically case-sensitive local
parts exist, but no real provider treats them that way and users
retype casing inconsistently), exact for everything else."""
normalized = destination_value.strip()
stmt = select(NotificationDestination).where(
NotificationDestination.user_id == user_id, NotificationDestination.type == type
)
if type == NotificationType.EMAIL:
stmt = stmt.where(
func.lower(NotificationDestination.destination_value) == normalized.lower()
)
else:
stmt = stmt.where(NotificationDestination.destination_value == normalized)
result = await self.db.execute(_with_companies(stmt))
return result.scalar_one_or_none()
async def create(
self,
*,
user_id: uuid.UUID,
type: NotificationType,
destination_value: str,
minimum_severity: SeverityLevel,
enabled: bool,
) -> NotificationDestination:
destination = NotificationDestination(
user_id=user_id,
type=type,
destination_value=destination_value,
minimum_severity=minimum_severity,
enabled=enabled,
)
self.db.add(destination)
await self.db.flush()
return destination
async def link_company(self, destination_id: uuid.UUID, company_id: uuid.UUID) -> None:
exists = await self.db.execute(
select(func.count())
.select_from(NotificationDestinationCompany)
.where(
NotificationDestinationCompany.destination_id == destination_id,
NotificationDestinationCompany.company_id == company_id,
)
)
if int(exists.scalar_one()) > 0:
return
self.db.add(
NotificationDestinationCompany(destination_id=destination_id, company_id=company_id)
)
await self.db.flush()
async def unlink_company(self, destination_id: uuid.UUID, company_id: uuid.UUID) -> None:
await self.db.execute(
delete(NotificationDestinationCompany).where(
NotificationDestinationCompany.destination_id == destination_id,
NotificationDestinationCompany.company_id == company_id,
)
)
await self.db.flush()
async def company_link_count(self, destination_id: uuid.UUID) -> int:
result = await self.db.execute(
select(func.count())
.select_from(NotificationDestinationCompany)
.where(NotificationDestinationCompany.destination_id == destination_id)
)
return int(result.scalar_one())
async def delete_orphaned_for_user(self, user_id: uuid.UUID) -> None:
"""Deletes any of this user's destinations that ended up linked to
zero companies - called after a company delete, since that cascades
the join rows for it but leaves the destination row itself behind
even when it was the destination's only remaining link."""
destinations = await self.list_for_user(user_id)
for destination in destinations:
if await self.company_link_count(destination.id) == 0:
await self.delete(destination)
async def delete(self, destination: NotificationDestination) -> None:
await self.db.delete(destination)
await self.db.flush()