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
+115
View File
@@ -0,0 +1,115 @@
"""Source CRUD + the ad-hoc "test this source" action. Collection
orchestration itself (persisting documents/snapshots) lives in
collection_service.py; this module is the ownership-checked API-facing
layer on top of it.
"""
from __future__ import annotations
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings
from app.core.errors import NotFoundError
from app.models.company import Company
from app.models.source import Source
from app.repositories.company_repository import CompanyRepository
from app.repositories.source_repository import SourceRepository
from app.schemas.source import SourceCreate, SourceUpdate
from app.services.collection_service import CollectionResult, collect_source
from app.services.scheduling import validate_and_compute_next_run
async def _get_owned_company(
db: AsyncSession, user_id: uuid.UUID, company_id: uuid.UUID
) -> Company:
company = await CompanyRepository(db).get_for_user(company_id, user_id)
if company is None:
raise NotFoundError("Company not found")
return company
async def list_sources(db: AsyncSession, user_id: uuid.UUID, company_id: uuid.UUID) -> list[Source]:
await _get_owned_company(db, user_id, company_id)
return await SourceRepository(db).list_for_company(company_id)
async def create_source(
db: AsyncSession, user_id: uuid.UUID, company_id: uuid.UUID, payload: SourceCreate
) -> Source:
await _get_owned_company(db, user_id, company_id)
source = await SourceRepository(db).create(
company_id=company_id,
source_type=payload.source_type,
name=payload.name,
base_url=payload.base_url,
)
await db.commit()
return source
async def _get_owned_source(db: AsyncSession, user_id: uuid.UUID, source_id: uuid.UUID) -> Source:
source = await SourceRepository(db).get_for_user(source_id, user_id)
if source is None:
raise NotFoundError("Source not found")
return source
async def update_source(
db: AsyncSession,
settings: Settings,
user_id: uuid.UUID,
source_id: uuid.UUID,
payload: SourceUpdate,
) -> Source:
source = await _get_owned_source(db, user_id, source_id)
updates = payload.model_dump(exclude_unset=True)
schedule_changed = any(
key in updates for key in ("frequency_type", "interval_minutes", "cron_expression")
)
if schedule_changed:
frequency_type = updates.get("frequency_type", source.frequency_type)
if frequency_type is not None:
company = await CompanyRepository(db).get_for_user(source.company_id, user_id)
tz_name = (
company.monitor_configuration.timezone
if company is not None and company.monitor_configuration is not None
else "UTC"
)
# Validate only - the actual next_check is computed for real the
# next time this source is collected (tasks/collection.py), same
# as a brand-new source. Resetting it to None here means a
# changed override takes effect on the very next scheduler tick
# rather than waiting out whatever cadence was previously set.
validate_and_compute_next_run(
frequency_type=frequency_type,
interval_minutes=updates.get("interval_minutes", source.interval_minutes),
cron_expression=updates.get("cron_expression", source.cron_expression),
tz_name=tz_name,
minimum_interval_minutes=settings.minimum_monitoring_interval_minutes,
)
source.next_check = None
for field, value in updates.items():
setattr(source, field, value)
await db.commit()
await db.refresh(source)
return source
async def delete_source(db: AsyncSession, user_id: uuid.UUID, source_id: uuid.UUID) -> None:
source = await _get_owned_source(db, user_id, source_id)
await SourceRepository(db).delete(source)
await db.commit()
async def test_source(
db: AsyncSession, settings: Settings, user_id: uuid.UUID, source_id: uuid.UUID
) -> CollectionResult:
source = await _get_owned_source(db, user_id, source_id)
company = await CompanyRepository(db).get_for_user(source.company_id, user_id)
if company is None: # pragma: no cover - defensive, implied by _get_owned_source
raise NotFoundError("Company not found")
return await collect_source(db, settings, source, company)