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).
150 lines
5.7 KiB
Python
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()
|