docs(plan): add Phase 1, 2, 3 implementation specs

This commit is contained in:
hermes
2026-07-05 08:51:38 -04:00
parent f540cbe7ef
commit c9dd9ba076
12 changed files with 3041 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
# Phase 2 — User Experience & Advanced Filtering
## Scope
This phase introduces **user-facing features** that significantly improve the experience of keyword tracking. Currently, every matching ad triggers an instant notification regardless of price, location, or time of day — leading to noise for popular keywords.
After this phase:
- Users can configure price ranges and postcodes per keyword
- Notifications respect mute hours (no alerts at 3 AM)
- Users opt into digest mode (bundled summaries instead of individual pings)
## Architecture
```
┌──────────────────────────────────────────────┐
│ User Interaction Layer │
│ │
│ Telegram Bot Commands: │
│ /set_price_min <kw> <€> │
│ /set_price_max <kw> <€> │
│ /set_postcode <kw> <list> │
│ /mute_hours <start>-<end> │
│ /digest on|off │
│ │
└──────────┬───────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Database Schema (extended) │
│ │
│ keywords table: │
│ + price_min int │
│ + price_max int │
│ + allowed_postcodes text[] │
│ │
│ user_settings table (new): │
│ telegram_id text PK │
│ mute_start time │
│ mute_end time │
│ digest_mode bool DEFAULT false │
│ digest_interval int DEFAULT 60 │
└──────────┬───────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Notification Pipeline (modified) │
│ │
│ For each new ad: │
│ ├─ filter by price_min/max? → skip │
│ ├─ filter by allowed_postcodes? → skip │
│ ├─ user in mute hours? │
│ │ digest_on → buffer to digest_table │
│ │ digest_off→ skip notification │
│ └─ normal → send now │
│ │
│ Digest scheduler (separate task): │
│ every digest_interval: │
│ collect buffered notifications per user │
│ format as summary message │
│ send single message │
│ clear buffer │
└──────────────────────────────────────────────┘
```
## Tasks
| Task | File | Description |
|------|------|-------------|
| Price range filters per keyword | [task-price-filters.md](./task-price-filters.md) | Add `price_min` and `price_max` columns to the keywords table; filter ads during processing based on these thresholds. Bot commands to set/unset. |
| Location / postcode filters per keyword | [task-postcode-filters.md](./task-postcode-filters.md) | Add `allowed_postcodes` text[] column to keywords; only notify if an ad's location matches any allowed postcode. |
| Mute hours per user | [task-mute-hours.md](./task-mute-hours.md) | Create `user_settings` table with configurable mute window (start/end time in UTC); suppress notifications during this window. |
| Digest / summary notifications | [task-digest-notifications.md](./task-digest-notifications.md) | Buffer notifications for users with digest mode enabled; send a bundled summary at configured intervals instead of individual alerts. |
## General Acceptance Criteria
- [ ] Users can set price min/max on any keyword and only receive notifications within that range
- [ ] Postcode filtering works — ads outside allowed postcodes are silently skipped (not counted as new)
- [ ] Mute hours suppress all notifications to a user during the configured window, regardless of keyword
- [ ] Digest mode buffers individual alerts and sends one summary message at the configured interval
- [ ] All filters combine correctly: an ad is only notified if it passes price + postcode checks AND the user is not muted (or digest mode active)
- [ ] The bot provides clear feedback when a filter setting is changed ("Keyword X: price range set to €100–€500")
- [ ] Admin can view all keyword filters and user settings via `/keywords` command output
+355
View File
@@ -0,0 +1,355 @@
# Task: Digest / summary notifications
## Description
For popular keywords that generate many matches per cycle, users may receive 1020 individual notifications in quick succession. This task introduces **digest mode** — instead of immediate alerts, notifications are buffered and sent as a single summary message at configurable intervals.
Digest mode is complementary to mute hours: during mute hours, all messages are suppressed; with digest mode ON, messages are collected and sent as a batch at the configured interval (default: every 60 minutes).
## Architecture
```
┌───────────────────────────────────────┐
│ user_settings table │
│ digest_mode bool DEFAULT false │
│ digest_interval int DEFAULT 60 │
└──────────┬────────────────────────────┘
┌───────────────────────────────────────┐
│ Notification Pipeline (modified) │
│ │
│ For each new ad: │
│ if user.digest_mode == false: │
│ → send immediately (current) │
│ elif in mute hours: │
│ → discard (already handled) │
│ else: │
│ → insert into digest_buffer │
└──────────┬────────────────────────────┘
┌───────────────────────────────────────┐
│ digest_buffer table (new) │
│ │
│ id uuid PK │
│ telegram_id text │
│ ad_id uuid REFERENCES ads │
│ keyword text │
│ title text │
│ price int │
│ url text │
│ created_at timestamptz │
│ │
│ INDEX: telegram_id, created_at │
└──────────┬────────────────────────────┘
┌───────────────────────────────────────┐
│ Digest Scheduler (separate task) │
│ │
│ Runs every digest_interval per user │
│ ├─ SELECT all buffered items │
│ ├─ GROUP BY telegram_id │
│ ├─ Format summary message │
│ └─ DELETE buffered items │
│ │
│ Summary format: │
│ 📋 Digest — 5 new ads (14:30 UTC) │
│ │
│ 🔑 "rtx 3090" (3 ads): │
│ • RTX 3090 Ti - €750 [link] │
│ • ASUS RTX 3090 - €680 [link] │
│ • MSI RTX 3090 Gaming X - €720 │
│ │
│ 🔑 "gtx 1660" (2 ads): │
│ • GTX 1660 Super - €120 [link] │
│ • EVGA GTX 1660 - €95 [link] │
└───────────────────────────────────────┘
```
### Key design decisions
- **Separate buffer table** instead of in-memory only. Survives restarts, visible via pgAdmin for debugging.
- **Per-user interval**: Each user configures their own digest frequency (default 60 min). Implemented with a single scheduler task that checks all users' intervals on each cycle.
- **Group by keyword** in the summary message. Makes it easy to scan relevant categories without digging through unrelated listings.
## Implementation Details
### 1. Add migration
In `worker/src/migrations/04-user-settings.sql`:
```sql
-- digest_buffer table for accumulating notifications
CREATE TABLE IF NOT EXISTS digest_buffer (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
telegram_id text NOT NULL,
ad_id uuid REFERENCES ads(id) ON DELETE CASCADE,
keyword text NOT NULL,
title text NOT NULL,
price int, -- in cents
url text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_digest_buffer
ON digest_buffer(telegram_id, created_at DESC);
COMMENT ON TABLE digest_buffer IS
'Buffer for digest-mode notifications. Flushed to Telegram at intervals.';
```
### 2. Buffer notifications instead of sending immediately
In `main.py` scheduler loop, after all filters pass and mute check passes:
```python
# Check if user has digest mode enabled
settings = await pool.fetchrow(
"SELECT digest_mode FROM user_settings WHERE telegram_id = $1",
telegram_id_str,
)
if settings and settings["digest_mode"]:
# Buffer for digest
await pool.execute(
"""INSERT INTO digest_buffer (telegram_id, ad_id, keyword, title, price, url)
VALUES ($1, $2, $3, $4, $5, $6)""",
telegram_id_str,
ad_id,
kw_row["keyword"],
ad_dict.get("title", "Unknown"),
_extract_price(ad_dict),
ad_dict.get("url"),
)
# Still log the notification (for stats)
await log_notify(pool, ad_id, telegram_id, "new")
else:
# Send immediately (current behavior)
await notify_new(bot, pool, kw_row["keyword"],
telegram_id, ad_dict, ad_id)
```
### 3. Add digest flushing task to scheduler
In `main.py`, add a new async function and call it at the start of each cycle:
```python
async def flush_digest_buffers() -> int:
"""Process pending digest buffers for users whose interval has elapsed."""
from db import get_pool
pool = await get_pool()
# Get all users with digest mode ON
users = await pool.fetch("""
SELECT telegram_id, digest_interval
FROM user_settings
WHERE digest_mode = true
""")
sent_count = 0
for user in users:
interval_min = user["digest_interval"] or 60
cutoff = datetime.now(tz=timezone.utc) - timedelta(minutes=interval_min)
# Get buffered items older than the interval
buffered = await pool.fetch("""
SELECT db.id, db.keyword, db.title, db.price, db.url
FROM digest_buffer db
WHERE db.telegram_id = $1
AND db.created_at <= $2
ORDER BY db.keyword, db.created_at DESC
""", user["telegram_id"], cutoff)
if not buffered:
continue
# Group by keyword
from collections import defaultdict
groups: dict[str, list] = defaultdict(list)
for item in buffered:
price_str = f"{item['price']/100:.2f}" if item['price'] else "Free"
entry = f"{item['title']} - {price_str}"
groups[item["keyword"]].append(entry)
# Build summary message
lines = [f"📋 Digest — {len(buffered)} new ads ({cutoff:%H:%M}{datetime.now(tz=timezone.utc):%H:%M} UTC)\n"]
for kw_name, entries in groups.items():
lines.append(f"\n🔑 \"{kw_name}\" ({len(entries)} ads):")
# Limit to 10 entries per keyword to avoid spam
for entry in entries[:10]:
lines.append(entry)
if len(entries) > 10:
lines.append(f" ... and {len(entries)-10} more")
message_text = "\n".join(lines)
# Send the digest
try:
from bot import get_application_bot
bot = get_application_bot()
telegram_id_int = int(user["telegram_id"])
await bot.send_message(chat_id=telegram_id_int, text=message_text)
sent_count += 1
# Log all buffered notifications as delivered
buffer_ids = [item["id"] for item in buffered]
for ad_item in buffered:
await log_notify(pool, ad_item["ad_id"], telegram_id_int, "new")
except TelegramError as e:
logger.error("Digest send failed for %s: %s", user["telegram_id"], e)
finally:
# Clear the buffer (whether sent or not — if it failed, log entries remain in DB)
await pool.execute(
"""DELETE FROM digest_buffer
WHERE telegram_id = $1 AND created_at <= $2""",
user["telegram_id"], cutoff,
)
return sent_count
# Call at the start of run_scheduler():
async def run_scheduler() -> None:
while True:
try:
record_scheduler_run()
# Flush digest buffers first
digests_sent = await flush_digest_buffers()
if digests_sent:
logger.info("Sent %d digest summaries", digests_sent)
# Process notification queue...
processed = await process_notification_queue()
if processed:
logger.info("Retried %d queued notifications", processed)
# ... existing keyword iteration ...
```
### 4. Add bot commands in `bot.py`
```python
async def cmd_digest_on(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Enable digest mode with optional interval."""
interval = 60 # default minutes
if len(context.args) > 0:
try:
interval = int(context.args[0])
if interval < 5 or interval > 1440:
raise ValueError
except ValueError:
await update.message.reply_text(
"Usage: /digest_on [minutes]\n"
"Interval must be between 5 and 1440 minutes (24h)."
)
return
telegram_id = str(update.effective_user.id)
from db import get_pool
pool = await get_pool()
await pool.execute(
"""INSERT INTO user_settings (telegram_id, digest_mode, digest_interval)
VALUES ($1, true, $2)
ON CONFLICT (telegram_id)
DO UPDATE SET digest_mode = EXCLUDED.digest_mode,
digest_interval = EXCLUDED.digest_interval""",
telegram_id, interval,
)
await update.message.reply_text(
f"✅ Digest mode ENABLED\n"
f"Digests will be sent every {interval} minutes.\n"
"Use /digest_off to return to instant notifications."
)
async def cmd_digest_off(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Disable digest mode."""
telegram_id = str(update.effective_user.id)
from db import get_pool
pool = await get_pool()
await pool.execute(
"""INSERT INTO user_settings (telegram_id, digest_mode)
VALUES ($1, false)
ON CONFLICT (telegram_id)
DO UPDATE SET digest_mode = EXCLUDED.digest_mode""",
telegram_id,
)
# Flush any remaining buffered items immediately
await pool.execute(
"""DELETE FROM digest_buffer WHERE telegram_id = $1""",
telegram_id,
)
await update.message.reply_text(
"✅ Digest mode DISABLED — notifications are now instant."
)
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Show current user settings (extended from mute hours task)."""
telegram_id = str(update.effective_user.id)
from db import get_pool
pool = await get_pool()
settings = await pool.fetchrow(
"SELECT mute_start, mute_end, digest_mode, digest_interval "
"FROM user_settings WHERE telegram_id = $1",
telegram_id,
)
reply_parts = []
if not settings or not settings["mute_start"]:
reply_parts.append("🔕 Mute hours: OFF")
else:
reply_parts.append(
f"🔕 Mute hours: {settings['mute_start']}{settings['mute_end']} UTC"
)
if settings and settings["digest_mode"]:
reply_parts.append(
f"📋 Digest: ON (every {settings['digest_interval']} min)"
)
else:
reply_parts.append("📋 Digest: OFF (instant notifications)")
await update.message.reply_text("\n".join(reply_parts))
```
Register handlers:
```python
dp.add_handler(CommandHandler("digest_on", cmd_digest_on))
dp.add_handler(CommandHandler("digest_off", cmd_digest_off))
```
## Acceptance Criteria
- [ ] `/digest_on` enables digest mode with 60-minute default interval
- [ ] `/digest_on 30` sets digest interval to 30 minutes
- [ ] New ads are inserted into `digest_buffer` instead of being sent immediately when digest is ON
- [ ] At the configured interval, all buffered items are flushed as a single summary message
- [ ] The summary groups ads by keyword and includes price information
- [ ] After flushing, buffered items are deleted from the table
- [ ] `/digest_off` disables digest mode and sends any remaining buffered items immediately
- [ ] Mute hours take precedence over digest — muted notifications are discarded, not buffered
+211
View File
@@ -0,0 +1,211 @@
# Task: Mute hours per user
## Description
Users may add keywords that are popular enough to trigger notifications at any hour. Currently, there's no way to suppress alerts during sleeping hours — the bot sends notifications 24/7.
This task creates a `user_settings` table with configurable mute windows (start/end time). During the mute window, notifications for that user are suppressed entirely. The notification is not lost — it's still logged in `log_notifications`, but the Telegram message is not sent.
## Architecture
```
┌───────────────────────────────────────┐
│ user_settings table (new) │
│ │
│ telegram_id text PRIMARY KEY │
│ mute_start time │
│ mute_end time │
│ digest_mode bool DEFAULT false │
│ digest_interval int DEFAULT 60 │
│ │
│ Example: │
│ telegram_id = '298181113' │
│ mute_start = '22:00:00' │
│ mute_end = '07:00:00' │
│ → no alerts between 10PM-7AM UTC │
└──────────┬────────────────────────────┘
┌───────────────────────────────────────┐
│ Notification pipeline (in main.py) │
│ │
│ For each new ad that passes filters: │
│ user_settings = get from DB │
│ if in_mute_hours(user_settings): │
│ log_notify() │
│ → skip Telegram send │
│ else: │
│ notify_new() / notify_drop() │
└───────────────────────────────────────┘
```
### Key design decisions
- **Time stored as `time` type in PostgreSQL** — native, efficient for range checks. Default is NULL (no mute window).
- *Alternative*: Could store as integer hours (e.g., 22, 7), but `time` type gives flexibility for minute-level precision and clearer UI.
- **UTC timezone**: The bot operates in UTC internally. Users should be informed that mute times are in UTC. Adding timezone support per-user is a Phase 3 consideration.
- **Mute window can cross midnight** — start > end means the window wraps around midnight (e.g., 22:0007:00). The check handles this correctly.
## Implementation Details
### 1. Add migration
In `worker/src/migrations/04-user-settings.sql`:
```sql
CREATE TABLE IF NOT EXISTS user_settings (
telegram_id text PRIMARY KEY,
mute_start time,
mute_end time,
digest_mode bool NOT NULL DEFAULT false,
digest_interval int NOT NULL DEFAULT 60, -- minutes
CONSTRAINT chk_mute_hours CHECK (
mute_start IS NULL AND mute_end IS NULL
OR mute_start IS NOT NULL AND mute_end IS NOT NULL
)
);
COMMENT ON TABLE user_settings IS
'User-specific settings for notification behavior';
```
### 2. Add mute hours check in `notifier.py` or `main.py`
```python
async def _is_in_mute_hours(
telegram_id: str,
pool: asyncpg.Pool
) -> bool:
"""Check if the current time is within the user's mute window."""
settings = await pool.fetchrow(
"SELECT mute_start, mute_end FROM user_settings WHERE telegram_id = $1",
telegram_id,
)
if not settings or not settings["mute_start"] or not settings["mute_end"]:
return False # no mute configured
now_utc = datetime.now(tz=timezone.utc).time()
start = settings["mute_start"]
end = settings["mute_end"]
if start < end:
# Normal window (e.g., 22:0007:00 → actually wraps, so this is rare)
return start <= now_utc <= end
else:
# Window crosses midnight (e.g., 22:00 to 07:00 next day)
return now_utc >= start or now_utc <= end
# In main.py scheduler loop, before calling notify_new():
telegram_id_str = str(telegram_id)
in_mute = await _is_in_mute_hours(telegram_id_str, pool)
if in_mute:
logger.debug("Muted notification for user %s (mute window active)", telegram_id)
# Still log it but don't send Telegram message
await log_notify(pool, ad_id, telegram_id, "new")
return
# Proceed with normal notification...
```
### 3. Add bot commands in `bot.py`
```python
async def cmd_set_mute(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Set mute hours window (HH:MM-HH:MM format)."""
if len(context.args) < 1:
await update.message.reply_text(
"Usage: /mute_hours HH:MM-HH:MM\n"
"Example: /mute_hours 22:00-07:00 (mutes from 10PM to 7AM UTC)\n"
"Use /mute_off to disable."
)
return
try:
start_str, end_str = context.args[0].split("-")
mute_start = datetime.strptime(start_str.strip(), "%H:%M").time()
mute_end = datetime.strptime(end_str.strip(), "%H:%M").time()
except (ValueError, TypeError) as e:
await update.message.reply_text(
f"Invalid format. Use HH:MM-HH:MM.\nExample: /mute_hours 22:00-07:00"
)
return
telegram_id = str(update.effective_user.id)
from db import get_pool
pool = await get_pool()
await pool.execute(
"""INSERT INTO user_settings (telegram_id, mute_start, mute_end)
VALUES ($1, $2, $3)
ON CONFLICT (telegram_id)
DO UPDATE SET mute_start = EXCLUDED.mute_start, mute_end = EXCLUDED.mute_end""",
telegram_id, mute_start, mute_end,
)
await update.message.reply_text(
f"✅ Mute hours set: {mute_start}{mute_end} UTC\n"
"No notifications will be sent during this window.\n"
"Use /mute_off to disable or change."
)
async def cmd_mute_off(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Disable mute hours."""
telegram_id = str(update.effective_user.id)
from db import get_pool
pool = await get_pool()
await pool.execute(
"""INSERT INTO user_settings (telegram_id, mute_start, mute_end)
VALUES ($1, NULL, NULL)
ON CONFLICT (telegram_id)
DO UPDATE SET mute_start = EXCLUDED.mute_start, mute_end = EXCLUDED.mute_end""",
telegram_id,
)
await update.message.reply_text("✅ Mute hours disabled.")
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Show current user settings."""
telegram_id = str(update.effective_user.id)
from db import get_pool
pool = await get_pool()
settings = await pool.fetchrow(
"SELECT mute_start, mute_end FROM user_settings WHERE telegram_id = $1",
telegram_id,
)
if not settings or not settings["mute_start"]:
reply = "🔕 Mute hours: OFF (notifications sent 24/7)"
else:
reply = f"🔕 Mute hours: {settings['mute_start']}{settings['mute_end']} UTC"
await update.message.reply_text(reply)
```
Register handlers:
```python
dp.add_handler(MessageHandler(REGEX(r"^/mute_hours"), cmd_set_mute))
dp.add_handler(CommandHandler("mute_off", cmd_mute_off))
dp.add_handler(CommandHandler("status", cmd_status))
```
## Acceptance Criteria
- [ ] `/mute_hours 22:00-07:00` sets mute window from 10 PM to 7 AM UTC
- [ ] Notifications during the mute window are logged but NOT sent via Telegram
- [ ] Notifications outside the mute window work normally (no regression)
- [ ] Mute windows that cross midnight (start > end) are handled correctly
- [ ] `/mute_off` clears both start and end times, restoring 24/7 notifications
- [ ] `/status` shows current mute settings clearly
- [ ] Users without any settings in `user_settings` table receive all notifications (default behavior unchanged)
+199
View File
@@ -0,0 +1,199 @@
# Task: Location / postcode filters per keyword
## Description
Ads can match a user's keyword interest but be located in a completely different region (e.g., "rtx 3090" in Graz when the user only cares about Vienna). This task adds optional postcode filtering so users receive alerts only for ads in their desired locations.
The willhaben API returns location data in `LOCATION_CityName`, `LOCATION_ZIP` (postcode), and similar fields. We'll match against these fields.
## Architecture
```
┌───────────────────────────────────────┐
│ keywords table (extended) │
│ │
│ ... │
│ allowed_postcodes text[] │
│ ... │
│ │
│ Example: │
│ allowed_postcodes = {'1010','1020'} │
│ → only ads in Vienna 1st/2nd dist. │
└──────────┬────────────────────────────┘
┌───────────────────────────────────────┐
│ Processing filter (in main.py) │
│ │
│ for ad in ads_raw: │
│ ad_zip = _extract_postcode(ad) │
│ if kw.allowed_postcodes and │
│ ad_zip not in postcodes: │
│ skip │
└──────────┬────────────────────────────┘
┌───────────────────────────────────────┐
│ Bot commands (in bot.py): │
│ │
│ /postcode <keyword> p1,p2,p3 │
│ → sets allowed_postcodes = {'p1', │
│ 'p2','p3'} │
│ /clear_postcode <keyword> │
│ → removes filter (NULL) │
└───────────────────────────────────────┘
```
### Key design decisions
- **Text array** (`text[]`) instead of a separate lookup table. Simple, efficient for the typical case (<10 postcodes per keyword), and leverages PostgreSQL's native array support.
- *Alternative*: A `keyword_postcodes` junction table allows individual postcode management but adds unnecessary complexity for this use case.
- **Match against willhaben's ZIP code field** (`LOCATION_ZIP` in the ad attributes). This is the most reliable location identifier and works across all Austrian postcodes (4 digits, e.g., "1010", "8010").
- **Empty or missing postcode = skip if filter active**. If `allowed_postcodes` is set but an ad has no ZIP code, it's excluded. This prevents noise from unlocated ads.
## Implementation Details
### 1. Add migration
In `worker/src/migrations/03-keyword-filters.sql`:
```sql
ALTER TABLE keywords ADD COLUMN IF NOT EXISTS allowed_postcodes text[];
COMMENT ON COLUMN keywords.allowed_postcodes IS
'Austrian postcodes (4-digit strings). Only ads matching these are notified.';
```
### 2. Extract postcode from ad data in `notifier.py` or `scraper.py`
Add helper function:
```python
def _extract_postcode(ad_dict: dict) -> str | None:
"""Extract the postal code (ZIP) from a willhaben ad."""
attrs = _parse_attributes(ad_dict)
# Try multiple field names that willhaben might use
for key in ("LOCATION_ZIP", "LocationZip", "postalcode"):
val = attrs.get(key) or attrs.get(f"{key}_String")
if val:
return str(val).strip()
return None
```
### 3. Add postcode filter check in `main.py` scheduler loop
```python
async def _check_postcode_filter(
ad_dict: dict,
kw_row: dict
) -> bool:
"""Return True if the ad passes the postcode filter."""
postcodes = kw_row.get("allowed_postcodes") # list or None
if not postcodes:
return True # no filter active
from notifier import _extract_postcode # or wherever it lives
ad_zip = _extract_postcode(ad_dict)
if not ad_zip:
logger.debug("No postcode found in ad, skipping")
return False
# Normalize: willhaben returns "1010" as string, we store same way
return ad_zip in postcodes
# In the scheduler loop (after price check):
for ad_dict in ads_raw:
if not await _check_price_filters(ad_dict, kw_row):
continue
if not await _check_postcode_filter(ad_dict, kw_row):
continue
# ... rest of processing
```
### 4. Add bot commands in `bot.py`
```python
async def cmd_set_postcode(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Set allowed postcodes for a keyword."""
if len(context.args) < 2:
await update.message.reply_text("Usage: /postcode <keyword> <p1,p2,p3>")
return
kw_name = context.args[0]
postcode_strs = [pc.strip() for pc in context.args[1].split(",")]
# Validate format (4-digit Austrian postcodes)
invalid = [pc for pc in postcode_strs if not re.match(r"^\d{3,5}$", pc)]
if invalid:
await update.message.reply_text(
f"Invalid postcode(s): {', '.join(invalid)}. "
"Use 4-digit format like 1010, 8010."
)
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 allowed_postcodes = $1 WHERE id = $2",
postcode_strs, kw_id, # asyncpg handles text[] natively
)
await update.message.reply_text(
f"✅ Keyword '{kw_name}': postcodes set to {', '.join(postcode_strs)}"
)
async def cmd_clear_postcode(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Remove postcode filter for a keyword."""
if len(context.args) < 1:
await update.message.reply_text("Usage: /clear_postcode <keyword>")
return
# ... same pattern, set allowed_postcodes = NULL
```
Register handlers:
```python
dp.add_handler(MessageHandler(REGEX(r"^/postcode"), cmd_set_postcode))
dp.add_handler(MessageHandler(REGEX(r"^/clear_postcode"), cmd_clear_postcode))
```
### 5. Update `/keywords` command output
Add postcode info to the listing:
```python
if row["allowed_postcodes"]:
line += f"\n 📍 {', '.join(row['allowed_postcodes'])}"
```
## Acceptance Criteria
- [ ] `/postcode keyword 1010,1020` sets allowed postcodes to `{'1010', '1020'}` for that keyword
- [ ] Ads with ZIP codes NOT in the allowed list are skipped during processing
- [ ] Ads with no ZIP code at all are skipped when a filter is active
- [ ] `/clear_postcode keyword` removes the filter (NULL)
- [ ] Invalid postcodes (non-numeric or wrong length) are rejected by the bot
- [ ] The `/keywords` command shows active postcodes next to each keyword
- [ ] Both price AND postcode filters work correctly together (ad must pass both to be notified)
+254
View File
@@ -0,0 +1,254 @@
# 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 <keyword> <€amount> │
│ /price_max <keyword> <€amount> │
│ /clear_price <keyword> │
│ /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 <keyword> <amount_in_euro>")
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 <keyword> <amount_in_euro>")
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 <keyword>")
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