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:
2026-08-05 10:48:20 -04:00
commit 1a4c80958f
365 changed files with 43541 additions and 0 deletions
@@ -0,0 +1,55 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.alert import Alert
from app.models.enums import SeverityLevel
class AlertRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get(self, alert_id: uuid.UUID) -> Alert | None:
return await self.db.get(Alert, alert_id)
async def get_for_user(self, alert_id: uuid.UUID, user_id: uuid.UUID) -> Alert | None:
result = await self.db.execute(
select(Alert).where(Alert.id == alert_id, Alert.user_id == user_id)
)
return result.scalar_one_or_none()
async def list_for_user(
self,
user_id: uuid.UUID,
*,
company_id: uuid.UUID | None = None,
severity: SeverityLevel | None = None,
read: bool | None = None,
resolved: bool | None = None,
since: datetime | None = None,
limit: int = 200,
) -> list[Alert]:
stmt = select(Alert).where(Alert.user_id == user_id)
if company_id is not None:
stmt = stmt.where(Alert.company_id == company_id)
if severity is not None:
stmt = stmt.where(Alert.severity == severity)
if read is not None:
stmt = stmt.where(Alert.read == read)
if resolved is not None:
stmt = stmt.where(Alert.resolved == resolved)
if since is not None:
stmt = stmt.where(Alert.created_at >= since)
stmt = stmt.order_by(Alert.created_at.desc()).limit(limit)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def create(self, alert: Alert) -> Alert:
self.db.add(alert)
await self.db.flush()
return alert
@@ -0,0 +1,57 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.company_enrichment import CompanyEnrichment
from app.models.enums import EnrichmentStatus
class CompanyEnrichmentRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get_for_company(self, company_id: uuid.UUID) -> CompanyEnrichment | None:
result = await self.db.execute(
select(CompanyEnrichment).where(CompanyEnrichment.company_id == company_id)
)
return result.scalar_one_or_none()
async def upsert(
self,
company_id: uuid.UUID,
*,
status: EnrichmentStatus,
data: dict[str, Any],
errors: dict[str, str],
credits_spent: int | None,
fetched_at: datetime,
) -> CompanyEnrichment:
"""One row per company (unique FK) - the second-ever call for a
company would only happen via a manual re-run, never automatically
(enrichment fires once, at onboarding - see enrichment_service)."""
existing = await self.get_for_company(company_id)
if existing is not None:
existing.status = status
existing.data = data
existing.errors = errors
existing.credits_spent = credits_spent
existing.fetched_at = fetched_at
await self.db.flush()
return existing
enrichment = CompanyEnrichment(
company_id=company_id,
status=status,
data=data,
errors=errors,
credits_spent=credits_spent,
fetched_at=fetched_at,
)
self.db.add(enrichment)
await self.db.flush()
return enrichment
@@ -0,0 +1,173 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.company import Company, CompanyAlias, Competitor
from app.models.monitor_configuration import MonitorConfiguration
def _with_relations(stmt):
return stmt.options(
selectinload(Company.aliases),
selectinload(Company.competitors),
selectinload(Company.monitor_configuration),
selectinload(Company.enrichment),
# Loaded eagerly so ORM-level cascade-delete (SQLite doesn't enforce
# FK ON DELETE CASCADE without a pragma this app doesn't set) can
# actually see the children to remove when a Company is deleted.
selectinload(Company.notification_links),
)
class CompanyRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get_for_user(self, company_id: uuid.UUID, user_id: uuid.UUID) -> Company | None:
stmt = _with_relations(
select(Company).where(Company.id == company_id, Company.user_id == user_id)
)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def get_by_id(self, company_id: uuid.UUID) -> Company | None:
"""Unscoped lookup for trusted internal callers (Celery tasks) that
already have the company_id from a source they control - not for
anything reachable from an HTTP request without an ownership check."""
stmt = _with_relations(select(Company).where(Company.id == company_id))
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def list_for_user(self, user_id: uuid.UUID) -> list[Company]:
stmt = _with_relations(
select(Company).where(Company.user_id == user_id).order_by(Company.created_at.desc())
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def count_for_user(self, user_id: uuid.UUID) -> int:
result = await self.db.execute(
select(func.count()).select_from(Company).where(Company.user_id == user_id)
)
return int(result.scalar_one())
async def slug_exists_for_user(self, user_id: uuid.UUID, slug: str) -> bool:
result = await self.db.execute(
select(func.count())
.select_from(Company)
.where(Company.user_id == user_id, Company.slug == slug)
)
return int(result.scalar_one()) > 0
async def name_exists_for_user(self, user_id: uuid.UUID, name: str) -> bool:
result = await self.db.execute(
select(func.count())
.select_from(Company)
.where(Company.user_id == user_id, func.lower(Company.name) == name.lower())
)
return int(result.scalar_one()) > 0
async def create(
self,
*,
user_id: uuid.UUID,
name: str,
slug: str,
official_website: str | None,
description: str | None,
monitoring_focus: str | None,
industry: str | None,
country: str | None,
region: str | None,
headquarters: str | None = None,
public_identifiers: dict[str, str] | None = None,
alias_names: list[str],
competitor_names: list[str],
) -> Company:
company = Company(
user_id=user_id,
name=name,
slug=slug,
official_website=official_website,
description=description,
monitoring_focus=monitoring_focus,
industry=industry,
country=country,
region=region,
headquarters=headquarters,
public_identifiers=public_identifiers or {},
)
company.aliases = [CompanyAlias(alias=a) for a in alias_names if a.strip()]
company.competitors = [Competitor(name=c) for c in competitor_names if c.strip()]
self.db.add(company)
await self.db.flush()
return company
async def delete(self, company: Company) -> None:
await self.db.delete(company)
await self.db.flush()
async def replace_aliases(self, company: Company, alias_names: list[str]) -> None:
company.aliases = [CompanyAlias(alias=a) for a in alias_names if a.strip()]
async def replace_competitors(self, company: Company, competitor_names: list[str]) -> None:
company.competitors = [Competitor(name=c) for c in competitor_names if c.strip()]
class MonitorConfigurationRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def create_default(
self,
*,
company_id: uuid.UUID,
frequency_type,
interval_minutes: int | None,
cron_expression: str | None,
timezone: str,
severity_threshold,
next_run,
) -> MonitorConfiguration:
config = MonitorConfiguration(
company_id=company_id,
frequency_type=frequency_type,
interval_minutes=interval_minutes,
cron_expression=cron_expression,
timezone=timezone,
severity_threshold=severity_threshold,
next_run=next_run,
)
self.db.add(config)
await self.db.flush()
return config
async def list_due(self, now: datetime) -> list[MonitorConfiguration]:
"""Enabled schedules whose next_run has arrived - what Celery Beat's
sync_schedules task polls instead of requiring a static per-company
beat_schedule entry (see ARCHITECTURE.md)."""
result = await self.db.execute(
select(MonitorConfiguration).where(
MonitorConfiguration.enabled.is_(True),
MonitorConfiguration.next_run.is_not(None),
MonitorConfiguration.next_run <= now,
)
)
return list(result.scalars().all())
async def list_enabled(self) -> list[MonitorConfiguration]:
"""Every enabled schedule regardless of next_run - since a company
can now also become due purely via a per-source frequency override
even when its own default next_run isn't due yet (see
SourceRepository.list_due_for_company), the scheduler needs this
broader set to check, not just the ones already due on the
company-level clock."""
result = await self.db.execute(
select(MonitorConfiguration).where(MonitorConfiguration.enabled.is_(True))
)
return list(result.scalars().all())
@@ -0,0 +1,51 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.detected_change import DetectedChange
from app.models.enums import ChangeType
class DetectedChangeRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get_recent_for_source_and_type(
self, source_id: uuid.UUID, change_type: ChangeType, since: datetime
) -> DetectedChange | None:
"""Most recent DetectedChange of this type for this source within
the cooldown window - used both to flag repeats (lower significance)
and to suppress exact duplicates (see change_detection_service.py)."""
result = await self.db.execute(
select(DetectedChange)
.where(
DetectedChange.source_id == source_id,
DetectedChange.change_type == change_type,
DetectedChange.created_at >= since,
)
.order_by(DetectedChange.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def create(self, **kwargs: Any) -> DetectedChange:
change = DetectedChange(**kwargs)
self.db.add(change)
await self.db.flush()
return change
async def list_for_company(
self, company_id: uuid.UUID, limit: int = 100
) -> list[DetectedChange]:
result = await self.db.execute(
select(DetectedChange)
.where(DetectedChange.company_id == company_id)
.order_by(DetectedChange.created_at.desc())
.limit(limit)
)
return list(result.scalars().all())
@@ -0,0 +1,69 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import ensure_aware_utc
from app.models.email_code import EmailCode
from app.models.enums import EmailCodePurpose
class EmailCodeRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def create(
self, *, user_id: uuid.UUID, purpose: EmailCodePurpose, code_hash: str, expires_at: datetime
) -> EmailCode:
record = EmailCode(
user_id=user_id, purpose=purpose, code_hash=code_hash, expires_at=expires_at
)
self.db.add(record)
await self.db.flush()
return record
async def get_latest_valid(
self, user_id: uuid.UUID, purpose: EmailCodePurpose, code_hash: str
) -> EmailCode | None:
"""Most recent unused, unexpired code for this user/purpose whose
hash matches. `invalidate_unused` is what actually guarantees only
the most recently issued code can ever satisfy this - this method
alone doesn't enforce that."""
result = await self.db.execute(
select(EmailCode)
.where(
EmailCode.user_id == user_id,
EmailCode.purpose == purpose,
EmailCode.code_hash == code_hash,
EmailCode.used_at.is_(None),
)
.order_by(EmailCode.created_at.desc())
)
for record in result.scalars().all():
if ensure_aware_utc(record.expires_at) > datetime.now(UTC):
return record
return None
async def mark_used(self, record: EmailCode) -> None:
record.used_at = datetime.now(UTC)
await self.db.flush()
async def invalidate_unused(self, user_id: uuid.UUID, purpose: EmailCodePurpose) -> None:
"""Called right before issuing a fresh code - a resend must fully
supersede every prior unused code for this purpose, not just make
them harder to guess. Without this, an old code (e.g. still sitting
in an old email) stays valid until it naturally expires, even after
the user has explicitly asked for a new one."""
await self.db.execute(
update(EmailCode)
.where(
EmailCode.user_id == user_id,
EmailCode.purpose == purpose,
EmailCode.used_at.is_(None),
)
.values(used_at=datetime.now(UTC))
)
await self.db.flush()
@@ -0,0 +1,64 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import ThrottleAction
from app.models.ip_ban import IpBan
from app.models.ip_throttle_state import IpThrottleState
class IpThrottleRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get_state(self, ip_address: str, action: ThrottleAction) -> IpThrottleState | None:
result = await self.db.execute(
select(IpThrottleState).where(
IpThrottleState.ip_address == ip_address, IpThrottleState.action == action
)
)
return result.scalar_one_or_none()
async def get_or_create_state(self, ip_address: str, action: ThrottleAction) -> IpThrottleState:
state = await self.get_state(ip_address, action)
if state is not None:
return state
state = IpThrottleState(ip_address=ip_address, action=action)
self.db.add(state)
await self.db.flush()
return state
async def get_ban(self, ip_address: str) -> IpBan | None:
result = await self.db.execute(select(IpBan).where(IpBan.ip_address == ip_address))
return result.scalar_one_or_none()
async def create_ban(self, ip_address: str, reason: str, banned_at: datetime) -> IpBan:
ban = IpBan(ip_address=ip_address, reason=reason, banned_at=banned_at)
self.db.add(ban)
await self.db.flush()
return ban
async def list_bans(self) -> list[IpBan]:
result = await self.db.execute(select(IpBan).order_by(IpBan.banned_at.desc()))
return list(result.scalars().all())
async def clear_ban_and_state(self, ip_address: str) -> bool:
"""Full pardon for an admin-approved unban - removes the ban record
and resets every throttle-state row for this IP to a clean slate
(not just lifting the terminal ban while leaving them one offense
from another)."""
ban_result = await self.db.execute(delete(IpBan).where(IpBan.ip_address == ip_address))
result = await self.db.execute(
select(IpThrottleState).where(IpThrottleState.ip_address == ip_address)
)
states = result.scalars().all()
for state in states:
state.attempt_count = 0
state.next_allowed_at = None
state.timeout_until = None
state.offense_count = 0
await self.db.flush()
return ban_result.rowcount > 0 or len(states) > 0
@@ -0,0 +1,124 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import MonitoringRunStatus, MonitoringRunTrigger
from app.models.monitoring_run import MonitoringRun
_ACTIVE_STATUSES = (MonitoringRunStatus.QUEUED, MonitoringRunStatus.RUNNING)
class MonitoringRunRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get(self, run_id: uuid.UUID) -> MonitoringRun | None:
return await self.db.get(MonitoringRun, run_id)
async def get_for_company(
self, run_id: uuid.UUID, company_id: uuid.UUID
) -> MonitoringRun | None:
result = await self.db.execute(
select(MonitoringRun).where(
MonitoringRun.id == run_id, MonitoringRun.company_id == company_id
)
)
return result.scalar_one_or_none()
async def list_for_company(self, company_id: uuid.UUID, limit: int = 50) -> list[MonitoringRun]:
result = await self.db.execute(
select(MonitoringRun)
.where(MonitoringRun.company_id == company_id)
.order_by(MonitoringRun.created_at.desc())
.limit(limit)
)
return list(result.scalars().all())
async def get_active_for_company(self, company_id: uuid.UUID) -> MonitoringRun | None:
"""The queued/running run for this company, if any - used both to
make "run now" idempotent and to stop the scheduler from double
enqueuing a company that's still mid-run."""
result = await self.db.execute(
select(MonitoringRun)
.where(
MonitoringRun.company_id == company_id,
MonitoringRun.status.in_(_ACTIVE_STATUSES),
)
.order_by(MonitoringRun.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def create(
self, *, company_id: uuid.UUID, trigger_type: MonitoringRunTrigger
) -> MonitoringRun:
run = MonitoringRun(
company_id=company_id,
trigger_type=trigger_type,
status=MonitoringRunStatus.QUEUED,
)
self.db.add(run)
await self.db.flush()
return run
async def set_worker_task_id(self, run: MonitoringRun, task_id: str | None) -> None:
run.worker_task_id = task_id
await self.db.flush()
async def mark_running(self, run: MonitoringRun) -> None:
run.status = MonitoringRunStatus.RUNNING
run.started_at = datetime.now(UTC)
await self.db.flush()
async def update_progress(
self,
run: MonitoringRun,
*,
sources_attempted: int,
sources_successful: int,
sources_failed: int,
items_collected: int,
changes_detected: int | None = None,
) -> None:
run.sources_attempted = sources_attempted
run.sources_successful = sources_successful
run.sources_failed = sources_failed
run.items_collected = items_collected
if changes_detected is not None:
run.changes_detected = changes_detected
await self.db.commit()
async def mark_finished(
self, run: MonitoringRun, *, status: MonitoringRunStatus, error_summary: str | None
) -> None:
run.status = status
run.error_summary = error_summary
run.completed_at = datetime.now(UTC)
await self.db.commit()
async def count_for_company(self, company_id: uuid.UUID) -> int:
result = await self.db.execute(
select(func.count())
.select_from(MonitoringRun)
.where(MonitoringRun.company_id == company_id)
)
return int(result.scalar_one())
async def count_manual_since(self, company_id: uuid.UUID, since: datetime) -> int:
"""Manual (user-triggered) runs for this company since `since` -
what enforces MAX_MANUAL_RUNS_PER_DAY (scheduled runs don't count
against it)."""
result = await self.db.execute(
select(func.count())
.select_from(MonitoringRun)
.where(
MonitoringRun.company_id == company_id,
MonitoringRun.trigger_type == MonitoringRunTrigger.MANUAL,
MonitoringRun.created_at >= since,
)
)
return int(result.scalar_one())
@@ -0,0 +1,24 @@
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.notification_delivery import NotificationDelivery
class NotificationDeliveryRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def create(self, delivery: NotificationDelivery) -> NotificationDelivery:
self.db.add(delivery)
await self.db.flush()
return delivery
async def list_for_alert(self, alert_id: uuid.UUID) -> list[NotificationDelivery]:
result = await self.db.execute(
select(NotificationDelivery).where(NotificationDelivery.alert_id == alert_id)
)
return list(result.scalars().all())
@@ -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()
@@ -0,0 +1,25 @@
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.password_history import PasswordHistoryEntry
class PasswordHistoryRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list_hashes_for_user(self, user_id: uuid.UUID) -> list[str]:
result = await self.db.execute(
select(PasswordHistoryEntry.password_hash).where(
PasswordHistoryEntry.user_id == user_id
)
)
return list(result.scalars().all())
async def add(self, *, user_id: uuid.UUID, password_hash: str) -> None:
self.db.add(PasswordHistoryEntry(user_id=user_id, password_hash=password_hash))
await self.db.flush()
@@ -0,0 +1,55 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import ensure_aware_utc
from app.models.refresh_token import RefreshToken
class RefreshTokenRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def create(
self, *, user_id: uuid.UUID, token_hash: str, expires_at: datetime
) -> RefreshToken:
record = RefreshToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at)
self.db.add(record)
await self.db.flush()
return record
async def get_valid_by_hash(self, token_hash: str) -> RefreshToken | None:
result = await self.db.execute(
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
)
record = result.scalar_one_or_none()
if record is None:
return None
if record.revoked_at is not None:
return None
if ensure_aware_utc(record.expires_at) < datetime.now(UTC):
return None
return record
async def revoke(self, record: RefreshToken) -> None:
record.revoked_at = datetime.now(UTC)
await self.db.flush()
async def revoke_all_for_user(self, user_id: uuid.UUID) -> None:
"""Invalidates every active session for this user - used after a
password reset, since an attacker who had a valid refresh token
shouldn't stay logged in past the password change that locked them
out going forward."""
result = await self.db.execute(
select(RefreshToken).where(
RefreshToken.user_id == user_id, RefreshToken.revoked_at.is_(None)
)
)
now = datetime.now(UTC)
for record in result.scalars().all():
record.revoked_at = now
await self.db.flush()
@@ -0,0 +1,46 @@
from __future__ import annotations
import uuid
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.report import Report
class ReportRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get(self, report_id: uuid.UUID) -> Report | None:
return await self.db.get(Report, report_id)
async def get_for_company(self, report_id: uuid.UUID, company_id: uuid.UUID) -> Report | None:
result = await self.db.execute(
select(Report).where(Report.id == report_id, Report.company_id == company_id)
)
return result.scalar_one_or_none()
async def list_for_company(self, company_id: uuid.UUID, limit: int = 50) -> list[Report]:
result = await self.db.execute(
select(Report)
.where(Report.company_id == company_id)
.order_by(Report.created_at.desc())
.limit(limit)
)
return list(result.scalars().all())
async def latest_for_company(self, company_id: uuid.UUID) -> Report | None:
result = await self.db.execute(
select(Report)
.where(Report.company_id == company_id)
.order_by(Report.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def count_for_company(self, company_id: uuid.UUID) -> int:
result = await self.db.execute(
select(func.count()).select_from(Report).where(Report.company_id == company_id)
)
return int(result.scalar_one())
@@ -0,0 +1,225 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import ensure_aware_utc
from app.models.company import Company
from app.models.enums import SourceStatus, SourceType
from app.models.snapshot import Snapshot
from app.models.source import Source
from app.models.source_document import SourceDocument
def _is_due(value: datetime | None, now: datetime) -> bool:
# SQLite silently drops tzinfo on read-back (Postgres does not) - any
# value read from the DB must go through ensure_aware_utc before being
# compared against an aware `now` in Python, or this comparison would
# raise on SQLite while working fine on Postgres. See db/base.py.
return value is not None and ensure_aware_utc(value) <= now
def _is_source_due(source: Source, now: datetime, company_next_run: datetime | None) -> bool:
"""A source with its own frequency override uses its own next_check
(due immediately if never computed yet); one with no override rides
the company's own next_run clock instead."""
if source.frequency_type is not None:
return source.next_check is None or _is_due(source.next_check, now)
return _is_due(company_next_run, now)
class SourceRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list_for_company(self, company_id: uuid.UUID) -> list[Source]:
result = await self.db.execute(
select(Source).where(Source.company_id == company_id).order_by(Source.created_at)
)
return list(result.scalars().all())
async def list_due_for_company(
self, company_id: uuid.UUID, now: datetime, company_next_run: datetime | None
) -> list[Source]:
"""Active sources that are due for a check right now. A source with
its own frequency override (frequency_type is not None) uses its
own next_check, and is treated as due if it's never been computed
yet (a brand-new override should get its first check immediately,
same as any brand-new source always has). A source with no
override rides the company's own next_run clock instead - this is
what keeps "no override configured" behaviorally identical to how
every source worked before per-source scheduling existed. Small
per-company table (sources per company is always small for this
app), so filtering in Python after one plain SELECT is simpler than
expressing the OR-with-NULL-fallback logic as SQL."""
sources = await self._list_active_for_company(company_id)
return [s for s in sources if _is_source_due(s, now, company_next_run)]
async def company_has_due_work(
self, company_id: uuid.UUID, now: datetime, company_next_run: datetime | None
) -> bool:
"""Whether the scheduler should enqueue a run for this company: a
company with zero sources yet has nothing to check per-source - it
stays gated purely by its own next_run (the company's first-ever
run, which is what triggers source discovery), same as before
per-source scheduling existed. A company with sources is due if any
active one is (see list_due_for_company)."""
sources = await self._list_active_for_company(company_id)
if not sources:
return _is_due(company_next_run, now)
return any(_is_source_due(s, now, company_next_run) for s in sources)
async def _list_active_for_company(self, company_id: uuid.UUID) -> list[Source]:
result = await self.db.execute(
select(Source).where(Source.company_id == company_id, Source.active.is_(True))
)
return list(result.scalars().all())
async def get_for_company(self, source_id: uuid.UUID, company_id: uuid.UUID) -> Source | None:
result = await self.db.execute(
select(Source).where(Source.id == source_id, Source.company_id == company_id)
)
return result.scalar_one_or_none()
async def get_for_user(self, source_id: uuid.UUID, user_id: uuid.UUID) -> Source | None:
"""Ownership-checked lookup that doesn't require the caller to
already know the company_id (matches the spec's `/sources/{id}`
routes, which aren't nested under `/companies/{company_id}`)."""
result = await self.db.execute(
select(Source)
.join(Company, Company.id == Source.company_id)
.where(Source.id == source_id, Company.user_id == user_id)
)
return result.scalar_one_or_none()
async def create(
self,
*,
company_id: uuid.UUID,
source_type: SourceType,
name: str,
base_url: str | None,
configuration_metadata: dict[str, Any] | None = None,
trust_score: float = 0.7,
) -> Source:
source = Source(
company_id=company_id,
source_type=source_type,
name=name,
base_url=base_url,
configuration_metadata=configuration_metadata or {},
trust_score=trust_score,
)
self.db.add(source)
await self.db.flush()
return source
async def delete(self, source: Source) -> None:
await self.db.delete(source)
await self.db.flush()
async def mark_checked(
self,
source: Source,
*,
status: SourceStatus,
checked_at: datetime,
success: bool,
) -> None:
source.status = status
source.last_checked = checked_at
if success:
source.last_successful_check = checked_at
source.failure_count = 0
else:
source.failure_count += 1
await self.db.flush()
class SourceDocumentRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def exists_with_hash(self, source_id: uuid.UUID, content_hash: str) -> bool:
result = await self.db.execute(
select(SourceDocument.id).where(
SourceDocument.source_id == source_id,
SourceDocument.content_hash == content_hash,
)
)
return result.scalar_one_or_none() is not None
async def create(self, **kwargs: Any) -> SourceDocument:
document = SourceDocument(**kwargs)
self.db.add(document)
await self.db.flush()
return document
async def latest_for_source(
self, source_id: uuid.UUID, limit: int = 50
) -> list[SourceDocument]:
result = await self.db.execute(
select(SourceDocument)
.where(SourceDocument.source_id == source_id)
.order_by(SourceDocument.retrieved_date.desc())
.limit(limit)
)
return list(result.scalars().all())
async def delete_older_than(self, cutoff: datetime) -> int:
"""Data-retention purge target (DATA_RETENTION_DAYS). Only
SourceDocument is in scope - nothing else has a foreign key onto it
(see KNOWN_LIMITATIONS.md), so this can't cascade-delete a Snapshot,
DetectedChange, Alert, or Report a user might still want to see."""
result = await self.db.execute(
delete(SourceDocument).where(SourceDocument.retrieved_date < cutoff)
)
return result.rowcount or 0
class SnapshotRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list_for_company(self, company_id: uuid.UUID, limit: int = 50) -> list[Snapshot]:
result = await self.db.execute(
select(Snapshot)
.where(Snapshot.company_id == company_id)
.order_by(Snapshot.created_at.desc())
.limit(limit)
)
return list(result.scalars().all())
async def latest_for_source(self, source_id: uuid.UUID) -> Snapshot | None:
result = await self.db.execute(
select(Snapshot)
.where(Snapshot.source_id == source_id)
.order_by(Snapshot.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def create(self, **kwargs: Any) -> Snapshot:
snapshot = Snapshot(**kwargs)
self.db.add(snapshot)
await self.db.flush()
return snapshot
async def get_previous(self, source_id: uuid.UUID, before: Snapshot) -> Snapshot | None:
"""The snapshot immediately preceding `before` for this source -
what change detection diffs the new snapshot against."""
result = await self.db.execute(
select(Snapshot)
.where(
Snapshot.source_id == source_id,
Snapshot.id != before.id,
Snapshot.created_at <= before.created_at,
)
.order_by(Snapshot.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
@@ -0,0 +1,37 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import SystemSecretKey
from app.models.system_secret import SystemSecret
class SystemSecretRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list_all(self) -> list[SystemSecret]:
result = await self.db.execute(select(SystemSecret))
return list(result.scalars().all())
async def get(self, key: SystemSecretKey) -> SystemSecret | None:
result = await self.db.execute(select(SystemSecret).where(SystemSecret.key == key))
return result.scalar_one_or_none()
async def upsert(self, key: SystemSecretKey, encrypted_value: str) -> SystemSecret:
existing = await self.get(key)
if existing is not None:
existing.encrypted_value = encrypted_value
await self.db.flush()
return existing
record = SystemSecret(key=key, encrypted_value=encrypted_value)
self.db.add(record)
await self.db.flush()
return record
async def delete(self, key: SystemSecretKey) -> None:
existing = await self.get(key)
if existing is not None:
await self.db.delete(existing)
await self.db.flush()
@@ -0,0 +1,53 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import ensure_aware_utc
from app.models.unban_request import UnbanRequest
class UnbanRequestRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get(self, request_id: uuid.UUID) -> UnbanRequest | None:
result = await self.db.execute(select(UnbanRequest).where(UnbanRequest.id == request_id))
return result.scalar_one_or_none()
async def delete(self, request_id: uuid.UUID) -> None:
request = await self.get(request_id)
if request is not None:
await self.db.delete(request)
await self.db.flush()
async def most_recent_for_ip(self, ip_address: str) -> UnbanRequest | None:
result = await self.db.execute(
select(UnbanRequest)
.where(UnbanRequest.ip_address == ip_address)
.order_by(UnbanRequest.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def within_cooldown(self, ip_address: str, cooldown_hours: int = 24) -> bool:
latest = await self.most_recent_for_ip(ip_address)
if latest is None:
return False
cutoff = datetime.now(UTC) - timedelta(hours=cooldown_hours)
return ensure_aware_utc(latest.created_at) > cutoff
async def create(self, ip_address: str, message: str | None) -> UnbanRequest:
record = UnbanRequest(ip_address=ip_address, message=message)
self.db.add(record)
await self.db.flush()
return record
async def list_all(self, limit: int = 100) -> list[UnbanRequest]:
result = await self.db.execute(
select(UnbanRequest).order_by(UnbanRequest.created_at.desc()).limit(limit)
)
return list(result.scalars().all())
@@ -0,0 +1,43 @@
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import ApiKeyProvider
from app.models.user_api_key import UserApiKey
class UserApiKeyRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list_for_user(self, user_id: uuid.UUID) -> list[UserApiKey]:
result = await self.db.execute(select(UserApiKey).where(UserApiKey.user_id == user_id))
return list(result.scalars().all())
async def get(self, user_id: uuid.UUID, provider: ApiKeyProvider) -> UserApiKey | None:
result = await self.db.execute(
select(UserApiKey).where(UserApiKey.user_id == user_id, UserApiKey.provider == provider)
)
return result.scalar_one_or_none()
async def upsert(
self, user_id: uuid.UUID, provider: ApiKeyProvider, encrypted_key: str
) -> UserApiKey:
existing = await self.get(user_id, provider)
if existing is not None:
existing.encrypted_key = encrypted_key
await self.db.flush()
return existing
record = UserApiKey(user_id=user_id, provider=provider, encrypted_key=encrypted_key)
self.db.add(record)
await self.db.flush()
return record
async def delete(self, user_id: uuid.UUID, provider: ApiKeyProvider) -> None:
existing = await self.get(user_id, provider)
if existing is not None:
await self.db.delete(existing)
await self.db.flush()
@@ -0,0 +1,47 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user_known_ip import UserKnownIp
class UserKnownIpRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get(self, user_id: uuid.UUID, ip_address: str) -> UserKnownIp | None:
result = await self.db.execute(
select(UserKnownIp).where(
UserKnownIp.user_id == user_id, UserKnownIp.ip_address == ip_address
)
)
return result.scalar_one_or_none()
async def list_for_user(self, user_id: uuid.UUID) -> list[UserKnownIp]:
result = await self.db.execute(
select(UserKnownIp)
.where(UserKnownIp.user_id == user_id)
.order_by(UserKnownIp.last_seen_at.desc())
)
return list(result.scalars().all())
async def record_login(self, user_id: uuid.UUID, ip_address: str, now: datetime) -> bool:
"""Touches last_seen_at for an already-known IP, or inserts a new
row for a genuinely new one. Returns True iff this IP was new for
this user - callers don't currently act on that, but it's the
natural hook a future "new IP" alert would use."""
existing = await self.get(user_id, ip_address)
if existing is not None:
existing.last_seen_at = now
await self.db.flush()
return False
record = UserKnownIp(
user_id=user_id, ip_address=ip_address, first_seen_at=now, last_seen_at=now
)
self.db.add(record)
await self.db.flush()
return True
@@ -0,0 +1,48 @@
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
class UserRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get_by_id(self, user_id: uuid.UUID) -> User | None:
return await self.db.get(User, user_id)
async def get_by_email(self, email: str) -> User | None:
result = await self.db.execute(select(User).where(User.email == email.lower()))
return result.scalar_one_or_none()
async def list_admin_emails(self) -> list[str]:
result = await self.db.execute(select(User.email).where(User.is_admin.is_(True)))
return list(result.scalars().all())
async def create(
self,
*,
email: str,
password_hash: str | None,
display_name: str,
timezone: str,
user_id: uuid.UUID | None = None,
is_admin: bool = False,
email_verified: bool = False,
) -> User:
user = User(
id=user_id or uuid.uuid4(),
email=email.lower(),
password_hash=password_hash,
display_name=display_name,
timezone=timezone,
is_admin=is_admin,
email_verified=email_verified,
)
self.db.add(user)
await self.db.flush()
return user
@@ -0,0 +1,42 @@
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import SecurityEventType
from app.models.user_security_event import UserSecurityEvent
class UserSecurityEventRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def create(
self, *, user_id: uuid.UUID, event_type: SecurityEventType, ip_address: str
) -> UserSecurityEvent:
record = UserSecurityEvent(user_id=user_id, event_type=event_type, ip_address=ip_address)
self.db.add(record)
await self.db.flush()
return record
async def most_recent_of_type(
self, user_id: uuid.UUID, event_type: SecurityEventType
) -> UserSecurityEvent | None:
result = await self.db.execute(
select(UserSecurityEvent)
.where(UserSecurityEvent.user_id == user_id, UserSecurityEvent.event_type == event_type)
.order_by(UserSecurityEvent.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def list_for_user(self, user_id: uuid.UUID, limit: int = 100) -> list[UserSecurityEvent]:
result = await self.db.execute(
select(UserSecurityEvent)
.where(UserSecurityEvent.user_id == user_id)
.order_by(UserSecurityEvent.created_at.desc())
.limit(limit)
)
return list(result.scalars().all())