import logging from repositories.watchlist_repository import watchlist_repository from repositories.seat_repository import seat_repository logger = logging.getLogger(__name__) # Per-seat cap on watched trips, keyed by tenant tier — bounds the nightly # cron's scrape volume predictably (seats x cap) instead of the old # "re-scrape every competitor's homepage" approach. 1/seat at Analyse # means a fully-seated ($349, 5 seats) tenant tops out at 5 real trip # scrapes a night, same ceiling as scraping every competitor once, but # now every alert is about a trip a PM actually chose to track instead of # homepage noise. Discover/Intelligence values are placeholders — those # tiers aren't built yet (CLAUDE.md §27) and will also get full catalogue # auto-discovery on top, so the watchlist there is "pin your priorities," # not the only detection mechanism. WATCHLIST_CAP_PER_SEAT = { 'analyse': 1, 'discover': 2, 'intelligence': 3, 'enterprise': 3, } def add_to_watchlist(tenant, seat, competitor_id, url, trip_name): """ Adds a specific competitor trip to a seat's personal watchlist, enforcing WATCHLIST_CAP_PER_SEAT[tenant['tier']] against the seat's current count first — same count-vs-cap shape as licence_service.validate_email()'s seat check and api.py's add_seat(). Never silently overwrites or evicts an existing item. Data flow: tenant + seat + competitor_id/url/trip_name → watchlist_repository.count_for_seat(seat['id']) → count >= cap for this tier → {'success': False, 'reason': ...} → otherwise → watchlist_repository.create() → {'success': True, 'item': created_record} """ cap = WATCHLIST_CAP_PER_SEAT.get(tenant.get('tier'), 1) current_count = watchlist_repository.count_for_seat(seat['id']) if current_count >= cap: return { 'success': False, 'reason': f'Your watchlist is full ({current_count}/{cap}). Remove a trip to add another.', } item = watchlist_repository.create({ 'tenant_id': tenant['id'], 'seat_id': seat['id'], 'competitor_id': competitor_id, 'url': url, 'trip_name': trip_name, }) return {'success': True, 'item': item} def remove_from_watchlist(item_id): """ Removes a watchlist item outright. Any active seat on the tenant may remove any item — no per-seat ownership lock, matching this app's existing no-fine-grained-permissions convention (e.g. CompetitorsPage edits aren't restricted to whoever added a competitor either). Returns True if a record was deleted, False if item_id was unknown. """ return watchlist_repository.delete(item_id) def list_watchlist_for_tenant(tenant_id): """ Returns every watched item for a tenant (all seats'), each enriched with the watching seat's email for display — a PM should be able to see what teammates are tracking even though the cap itself is per-seat. A seat that's since been removed (seat_repository.get_by_id returns None) still shows the item with watched_by_email=None rather than raising, so a removed teammate's picks don't break the whole list. Data flow: tenant_id → watchlist_repository.list_for_tenant() → per item: seat_repository.get_by_id(item['seat_id']) → item + watched_by_email merged → list returned """ items = watchlist_repository.list_for_tenant(tenant_id) enriched = [] for item in items: seat = seat_repository.get_by_id(item.get('seat_id')) enriched.append({**item, 'watched_by_email': seat.get('email') if seat else None}) return enriched