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