# Task: Price range filters per keyword ## Description Currently, every ad that matches a keyword triggers a notification regardless of price. For keywords like "rtx" or "3090", this means users receive alerts for €5 listings (accessories) alongside relevant hardware deals. This task adds `price_min` and `price_max` columns to the `keywords` table and implements server-side filtering during ad processing. Users set these via bot commands, and ads outside the range are silently skipped without being stored in the `ads` table or triggering notifications. ## Architecture ``` ┌───────────────────────────────────────┐ │ keywords table (extended) │ │ │ │ ... │ │ price_min int │ │ price_max int │ │ ... │ └──────────┬────────────────────────────┘ │ ▼ ┌───────────────────────────────────────┐ │ Processing pipeline (in main.py) │ │ │ │ for ad in ads_raw: │ │ price = _extract_price(ad) │ │ if price is None: │ │ continue │ │ if kw.price_min and price < min: │ │ skip (below minimum) │ │ if kw.price_max and price > max: │ │ skip (above maximum) │ │ → process ad normally │ └──────────┬────────────────────────────┘ │ ▼ ┌───────────────────────────────────────┐ │ Bot commands (in bot.py): │ │ │ │ /price_min <€amount> │ │ /price_max <€amount> │ │ /clear_price │ │ /keywords → shows price ranges │ └───────────────────────────────────────┘ ``` ### Key design decisions - **Filter during processing, not at DB time** — the filter is applied in Python before inserting into `ads`. This keeps the `ads` table clean (only relevant ads are stored) and avoids extra WHERE clauses on every scrape cycle. - *Alternative*: Could use a computed column or trigger, but adds complexity for simple numeric comparison. - **Both filters optional** — NULL = no limit. Users can set only min, only max, both, or neither. - **Price extraction from ad data**: Use the existing `_extract_price()` function in `notifier.py`. If price is not available (free items), treat as €0 for filtering purposes. ## Implementation Details ### 1. Add migration In `worker/src/migrations/03-keyword-filters.sql`: ```sql ALTER TABLE keywords ADD COLUMN IF NOT EXISTS price_min int, ADD COLUMN IF NOT EXISTS price_max int; COMMENT ON COLUMN keywords.price_min IS 'Minimum price in cents (e.g. 5000 = €50). NULL = no limit.'; COMMENT ON COLUMN keywords.price_max IS 'Maximum price in cents (e.g. 500000 = €5000). NULL = no limit.'; ``` ### 2. Update `main.py` — filter ads by price during processing In the scheduler's keyword loop, before calling `_process_ad()`: ```python # After extracting ad info in _process_ad(): async def _check_price_filters( ad_dict: dict, kw_row: dict # from keywords table ) -> bool: """Return True if the ad passes price filters for this keyword.""" from notifier import _extract_price price = _extract_price(ad_dict) if price is None: # No price found — include it (could be "free" or missing data) return True price_min = kw_row.get("price_min") price_max = kw_row.get("price_max") if price_min is not None and price < price_min: logger.debug( "Price filter skip: ad %s price=%d min=%d", ad_id, price, price_min ) return False if price_max is not None and price > price_max: logger.debug( "Price filter skip: ad %s price=%d max=%d", ad_id, price, price_max ) return False return True # In the scheduler loop: for kw_row in keywords: if not kw_row["is_active"]: continue ads_raw = await fetch_ads(kw_row["keyword"]) for ad_dict in ads_raw: # Check price filters BEFORE processing if not await _check_price_filters(ad_dict, kw_row): continue # Existing _process_ad logic continues here... ``` ### 3. Add bot commands in `bot.py` ```python from telegram import Message from telegram.ext import ContextTypes async def cmd_set_price_min(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Set minimum price for a keyword.""" if len(context.args) < 2: await update.message.reply_text("Usage: /price_min ") return kw_name = context.args[0] try: amount_eur = float(context.args[1]) price_cents = int(amount_eur * 100) except ValueError: await update.message.reply_text("Invalid amount. Use a number like 50 or 99.99") return telegram_id = str(update.effective_user.id) from db import get_pool pool = await get_pool() # Find keyword by name for this user kw_id = await pool.fetchval( """SELECT id FROM keywords WHERE LOWER(keyword_name) = $1 AND telegram_id = $2""", kw_name.lower(), telegram_id, ) if not kw_id: await update.message.reply_text(f"Keyword '{kw_name}' not found for your account.") return await pool.execute( "UPDATE keywords SET price_min = $1 WHERE id = $2", price_cents, kw_id, ) await update.message.reply_text( f"✅ Keyword '{kw_name}': minimum price set to €{amount_eur:.2f}" ) async def cmd_set_price_max(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Set maximum price for a keyword.""" # Same pattern as cmd_set_price_min but sets price_max if len(context.args) < 2: await update.message.reply_text("Usage: /price_max ") return kw_name = context.args[0] try: amount_eur = float(context.args[1]) price_cents = int(amount_eur * 100) except ValueError: await update.message.reply_text("Invalid amount. Use a number like 50 or 99.99") return telegram_id = str(update.effective_user.id) from db import get_pool pool = await get_pool() kw_id = await pool.fetchval( """SELECT id FROM keywords WHERE LOWER(keyword_name) = $1 AND telegram_id = $2""", kw_name.lower(), telegram_id, ) if not kw_id: await update.message.reply_text(f"Keyword '{kw_name}' not found.") return await pool.execute( "UPDATE keywords SET price_max = $1 WHERE id = $2", price_cents, kw_id, ) await update.message.reply_text( f"✅ Keyword '{kw_name}': maximum price set to €{amount_eur:.2f}" ) async def cmd_clear_price(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Clear price filters for a keyword.""" if len(context.args) < 1: await update.message.reply_text("Usage: /clear_price ") return # ... similar pattern, set both to NULL ``` Register handlers in `register_handlers()`: ```python dp.add_handler(MessageHandler( REGEX(r"^/price_min"), cmd_set_price_min)) dp.add_handler(MessageHandler( REGEX(r"^/price_max"), cmd_set_price_max)) dp.add_handler(MessageHandler( REGEX(r"^/clear_price"), cmd_clear_price)) ``` ### 4. Update `/keywords` command output to show price ranges In the existing `/keywords` handler in `bot.py`, add: ```python price_info = "" if row["price_min"] is not None: price_info += f"min €{row['price_min']/100:.2f} " if row["price_max"] is not None: price_info += f"max €{row['price_max']/100:.2f}" if price_info: line += f"\n 💰 {price_info.strip()}" ``` ## Acceptance Criteria - [ ] `/price_min keyword 50` sets the minimum price to €50.00 (stored as 5000 cents) for that keyword - [ ] `/price_max keyword 1000` sets the maximum price to €1000.00 - [ ] Ads with price below `price_min` are skipped during processing and not inserted into `ads` table - [ ] Ads with price above `price_max` are skipped during processing - [ ] `/clear_price keyword` removes both limits (sets to NULL) - [ ] Keywords without price filters continue to work as before (no regression) - [ ] The `/keywords` command displays the active price range for each keyword - [ ] Non-admin users can only modify their own keywords' price filters