"""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