app.bettersight.io/backend/services/watchlist_service.py

89 lines
3.6 KiB
Python

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