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
+243
View File
@@ -0,0 +1,243 @@
"""Company + monitor configuration business logic.
Every lookup here is scoped to `user_id` at the query level (see
CompanyRepository), so a company that exists but belongs to another user
raises NotFoundError exactly like one that doesn't exist - this avoids
leaking existence via a 403-vs-404 timing/response difference.
"""
from __future__ import annotations
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings
from app.core.errors import ConflictError, NotFoundError
from app.core.text import slugify
from app.models.company import Company
from app.models.enums import CompanyStatus, EnrichmentStatus
from app.models.monitor_configuration import MonitorConfiguration
from app.repositories.company_enrichment_repository import CompanyEnrichmentRepository
from app.repositories.company_repository import CompanyRepository, MonitorConfigurationRepository
from app.repositories.notification_destination_repository import (
NotificationDestinationRepository,
)
from app.schemas.company import CompanyCreate, CompanyUpdate, MonitorConfigurationUpdate
from app.services.scheduling import validate_and_compute_next_run
async def _unique_slug(repo: CompanyRepository, user_id: uuid.UUID, name: str) -> str:
base = slugify(name)
slug = base
suffix = 1
while await repo.slug_exists_for_user(user_id, slug):
suffix += 1
slug = f"{base}-{suffix}"
return slug
async def _unique_display_name(repo: CompanyRepository, user_id: uuid.UUID, name: str) -> str:
"""Guarantees the created row's name is unique for this user, the same
way a filesystem silently renames a colliding file - "Stripe" stays
"Stripe" unless the user already has one, in which case this becomes
"Stripe (2)", "Stripe (3)", etc. The wizard warns about likely
duplicates *before* this ever runs (see the frontend's own near-duplicate
check) so this is a last-resort guarantee, not the primary UX."""
if not await repo.name_exists_for_user(user_id, name):
return name
suffix = 2
while await repo.name_exists_for_user(user_id, f"{name} ({suffix})"):
suffix += 1
return f"{name} ({suffix})"
async def list_companies(db: AsyncSession, user_id: uuid.UUID) -> list[Company]:
repo = CompanyRepository(db)
return await repo.list_for_user(user_id)
async def get_company(db: AsyncSession, user_id: uuid.UUID, company_id: uuid.UUID) -> Company:
repo = CompanyRepository(db)
company = await repo.get_for_user(company_id, user_id)
if company is None:
raise NotFoundError("Company not found")
return company
async def create_company(
db: AsyncSession, settings: Settings, user_id: uuid.UUID, payload: CompanyCreate
) -> Company:
company_repo = CompanyRepository(db)
monitor_repo = MonitorConfigurationRepository(db)
existing_count = await company_repo.count_for_user(user_id)
if existing_count >= settings.max_companies_per_user:
raise ConflictError(
f"You've reached the maximum of {settings.max_companies_per_user} monitored companies"
)
name = await _unique_display_name(company_repo, user_id, payload.name)
slug = await _unique_slug(company_repo, user_id, payload.name)
next_run = validate_and_compute_next_run(
frequency_type=payload.frequency_type,
interval_minutes=payload.interval_minutes,
cron_expression=payload.cron_expression,
tz_name=payload.timezone,
minimum_interval_minutes=settings.minimum_monitoring_interval_minutes,
)
company = await company_repo.create(
user_id=user_id,
name=name,
slug=slug,
official_website=payload.official_website,
description=payload.description,
monitoring_focus=payload.monitoring_focus,
industry=payload.industry,
country=payload.country,
region=payload.region,
headquarters=payload.headquarters,
public_identifiers=payload.public_identifiers,
alias_names=payload.alias_names,
competitor_names=payload.competitor_names,
)
await monitor_repo.create_default(
company_id=company.id,
frequency_type=payload.frequency_type,
interval_minutes=payload.interval_minutes,
cron_expression=payload.cron_expression,
timezone=payload.timezone,
severity_threshold=payload.severity_threshold,
next_run=next_run,
)
if settings.ninjapear_api_key:
# A PENDING row is created up front (not just enqueued) so the
# frontend has something real to poll on - otherwise "not yet
# enriched" and "never configured" would look identical (both
# `enrichment: null`). enrichment_service.enrich_company updates
# this same row in place once the task finishes.
await CompanyEnrichmentRepository(db).upsert(
company.id,
status=EnrichmentStatus.PENDING,
data={},
errors={},
credits_spent=None,
fetched_at=None,
)
await db.commit()
if settings.ninjapear_api_key:
# Fire-and-forget, onboarding-only enrichment - gated on the key
# itself (not just falling through to a mock provider) so a user
# who never configured NinjaPear gets zero extra background-task
# volume. See app/services/enrichment_service.py.
from app.tasks.enrichment import (
enrich_company, # local import: keeps Celery out of API startup path
)
enrich_company.delay(str(company.id))
refreshed = await company_repo.get_for_user(company.id, user_id)
assert refreshed is not None
return refreshed
async def update_company(
db: AsyncSession, user_id: uuid.UUID, company_id: uuid.UUID, payload: CompanyUpdate
) -> Company:
company_repo = CompanyRepository(db)
company = await get_company(db, user_id, company_id)
updates = payload.model_dump(exclude_unset=True, exclude={"alias_names", "competitor_names"})
for field, value in updates.items():
setattr(company, field, value)
if payload.alias_names is not None:
await company_repo.replace_aliases(company, payload.alias_names)
if payload.competitor_names is not None:
await company_repo.replace_competitors(company, payload.competitor_names)
await db.commit()
refreshed = await company_repo.get_for_user(company_id, user_id)
assert refreshed is not None
return refreshed
async def delete_company(db: AsyncSession, user_id: uuid.UUID, company_id: uuid.UUID) -> None:
company_repo = CompanyRepository(db)
company = await get_company(db, user_id, company_id)
await company_repo.delete(company)
await db.commit()
# Garbage-collect any notification destination that was only ever
# linked to this now-deleted company - a destination with zero company
# links left behind is dead weight, not a valid "applies to nothing"
# state (see NotificationDestinationRepository.delete_orphaned_for_user).
await NotificationDestinationRepository(db).delete_orphaned_for_user(user_id)
await db.commit()
async def _set_company_status(
db: AsyncSession, user_id: uuid.UUID, company_id: uuid.UUID, status: CompanyStatus
) -> Company:
company = await get_company(db, user_id, company_id)
company.status = status
if company.monitor_configuration is not None:
company.monitor_configuration.enabled = status == CompanyStatus.ACTIVE
await db.commit()
company_repo = CompanyRepository(db)
refreshed = await company_repo.get_for_user(company_id, user_id)
assert refreshed is not None
return refreshed
async def pause_company(db: AsyncSession, user_id: uuid.UUID, company_id: uuid.UUID) -> Company:
return await _set_company_status(db, user_id, company_id, CompanyStatus.PAUSED)
async def resume_company(db: AsyncSession, user_id: uuid.UUID, company_id: uuid.UUID) -> Company:
return await _set_company_status(db, user_id, company_id, CompanyStatus.ACTIVE)
async def update_monitor_configuration(
db: AsyncSession,
settings: Settings,
user_id: uuid.UUID,
company_id: uuid.UUID,
payload: MonitorConfigurationUpdate,
) -> MonitorConfiguration:
company = await get_company(db, user_id, company_id)
config = company.monitor_configuration
if config is None:
raise NotFoundError("Monitor configuration not found")
updates = payload.model_dump(exclude_unset=True)
frequency_type = updates.get("frequency_type", config.frequency_type)
interval_minutes = updates.get("interval_minutes", config.interval_minutes)
cron_expression = updates.get("cron_expression", config.cron_expression)
tz_name = updates.get("timezone", config.timezone)
schedule_changed = any(
key in updates
for key in ("frequency_type", "interval_minutes", "cron_expression", "timezone")
)
if schedule_changed:
config.next_run = validate_and_compute_next_run(
frequency_type=frequency_type,
interval_minutes=interval_minutes,
cron_expression=cron_expression,
tz_name=tz_name,
minimum_interval_minutes=settings.minimum_monitoring_interval_minutes,
)
for field, value in updates.items():
setattr(config, field, value)
if "enabled" in updates:
company.status = CompanyStatus.ACTIVE if updates["enabled"] else CompanyStatus.PAUSED
await db.commit()
await db.refresh(config)
return config