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,149 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user