Phase 5 — Automation:
- Daily scrape cron, daily digest email, and Monday brief n8n workflows
- price_history_repository.py + price diffing/is_new detection wired
into analysis_service's refresh path
- Numeric unread-count badge on the topbar bell (was a plain dot)
Change detection architecture correction:
- CLAUDE.md's Firecrawl Monitor design doesn't match the real
self-hosted product: no /v1/monitor in v1, the real /v2/monitor is
cron-scheduled and credit-metered (Supabase-gated), and self-hosted
Firecrawl is a 3-service stack (api/worker/playwright-service), not
the single container originally spec'd
- Replaced with content-hash comparison computed during the scrape
itself (core/scraper.py's compute_content_hash/normalise_html,
analysis_service's detect_change()) — no external dependency, and
the refresh path now skips LLM extraction entirely when a scrape
finds no content diff
- Fixed a real bug in the first pass of this migration: change_detected
was set and then immediately reset within the same function call,
making it unobservable to the CompetitorsPage badge, the Apps Script
sidebar, and the n8n cron filter. It now stays visible until the
next scrape confirms nothing further changed.
- Added a composite index on scrape_runs(competitor_id, content_hash,
started_at) backing the per-competitor recency lookup
- Docker Compose's firecrawl service replaced with the real
firecrawl-api/firecrawl-worker/firecrawl-playwright stack, gated
behind an opt-in profile
- Added CORS (absent from the original spec entirely) so the
browser-based dashboard can reach the API cross-origin
Phase 6 — Semantic trip matching:
- client_trips_repository.py — this collection existed in schema but
nothing ever populated it; comparable-trip matching could never
produce a real match without it
- embedding_service.save_client_trip() upserts a client_trips record
from every /research submission's trip-intent description, with
stale-embedding invalidation when destination/duration change
- /internal/embed-products now embeds client_trips as well as
competitor products; /internal/match-comparable reads both sides
from PocketBase instead of expecting client_trips in the request
body, so it's callable unattended by n8n
- comparable_matches upserts on (client_product, competitor_product)
instead of always creating — first_matched/last_matched only make
sense if repeat weekly matches update in place, and a PM's dismissed
match now survives re-matching
- embedding_and_matching_cron.json — Sunday night n8n workflow
249 tests passing, 96% coverage.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Closes gaps identified after Phase 3: the four email templates named
in CLAUDE.md's file tree (§5) were only partially built (weekly brief
only). Adds:
- welcome.html, trial_reminder.html, price_alert.html, new_product.html
— matching the brief.html styling pattern
- services/email_service.py — send_welcome_email(),
send_trial_reminder_email(), send_price_alert_email(),
send_new_product_email(), all via the same plain-Jinja2 + Resend
pattern as brief_service.py (side-effect failures logged, never raised)
- Three new internal routes for n8n/manual-onboarding to call:
POST /internal/welcome-email/<tenant_id>
POST /internal/trial-reminder/<tenant_id>
POST /internal/alert-email/<alert_id> (dispatches to price_alert or
new_product template by alert_type, marks delivered_email on success)
- alert_repository.mark_email_delivered() to back that last route
Also adds the "Contact support" (Freescout) placeholder link CLAUDE.md
calls for in both the sidebar (visible on every page) and AccountPage,
pointing at support.bettersight.io. The actual Freescout container,
DNS/Pangolin routing, and mailbox are real infra this backend can't
provision — the link is a placeholder until that's deployed.
216 backend tests passing, 95% coverage, 100% on the new email_service.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>