diff --git a/web/src/padelnomics/admin/pseo_routes.py b/web/src/padelnomics/admin/pseo_routes.py index af7fdf0..2200930 100644 --- a/web/src/padelnomics/admin/pseo_routes.py +++ b/web/src/padelnomics/admin/pseo_routes.py @@ -80,6 +80,9 @@ async def pseo_dashboard(): total_published = sum(r["stats"]["published"] for r in template_rows) stale_count = sum(1 for f in freshness if f["status"] == "stale") + noindex_row = await fetch_one("SELECT COUNT(*) as cnt FROM articles WHERE noindex = 1") + noindex_count = noindex_row["cnt"] if noindex_row else 0 + # Recent generation jobs — enough for the dashboard summary. jobs = await fetch_all( "SELECT id, task_name, status, progress_current, progress_total," @@ -95,6 +98,7 @@ async def pseo_dashboard(): total_published=total_published, total_templates=len(templates), stale_count=stale_count, + noindex_count=noindex_count, jobs=jobs, admin_page="pseo", ) diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 19167d5..926cff7 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -985,6 +985,30 @@ async def supplier_detail(supplier_id: int): ) enquiry_count = enquiry_row["cnt"] if enquiry_row else 0 + # Email activity timeline — correlate by contact_email (no FK) + timeline = [] + contact_email = supplier["contact_email"] if supplier else None + if contact_email: + sent = await fetch_all( + """SELECT created_at, subject, 'sent' AS direction + FROM email_log + WHERE to_addr = ? AND email_type = 'outreach' + ORDER BY created_at DESC LIMIT 50""", + (contact_email,), + ) + received = await fetch_all( + """SELECT received_at AS created_at, subject, 'received' AS direction + FROM inbound_emails + WHERE from_addr = ? + ORDER BY received_at DESC LIMIT 50""", + (contact_email,), + ) + timeline = sorted( + list(sent) + list(received), + key=lambda x: x["created_at"] or "", + reverse=True, + )[:50] + return await render_template( "admin/supplier_detail.html", supplier=supplier, @@ -994,6 +1018,7 @@ async def supplier_detail(supplier_id: int): boosts=boosts, forwards=forwards, enquiry_count=enquiry_count, + timeline=timeline, ) @@ -2595,6 +2620,15 @@ _CSV_OPTIONAL = {"country_code", "category", "website"} _CSV_IMPORT_LIMIT = 500 # guard against huge uploads +async def get_follow_up_due_count() -> int: + """Count pipeline suppliers with follow_up_at <= today.""" + row = await fetch_one( + """SELECT COUNT(*) as cnt FROM suppliers + WHERE outreach_status IS NOT NULL AND follow_up_at <= date('now')""" + ) + return row["cnt"] if row else 0 + + async def get_outreach_pipeline() -> dict: """Count suppliers per outreach status for the pipeline summary cards.""" rows = await fetch_all( @@ -2614,6 +2648,7 @@ async def get_outreach_suppliers( status: str = None, country: str = None, search: str = None, + follow_up: str = None, page: int = 1, per_page: int = 50, ) -> list[dict]: @@ -2630,6 +2665,10 @@ async def get_outreach_suppliers( if search: wheres.append("(name LIKE ? OR contact_email LIKE ?)") params.extend([f"%{search}%", f"%{search}%"]) + if follow_up == "due": + wheres.append("follow_up_at <= date('now')") + elif follow_up == "set": + wheres.append("follow_up_at IS NOT NULL") where = " AND ".join(wheres) offset = (page - 1) * per_page @@ -2638,7 +2677,7 @@ async def get_outreach_suppliers( return await fetch_all( f"""SELECT id, name, country_code, category, contact_email, outreach_status, outreach_notes, last_contacted_at, - outreach_sequence_step + outreach_sequence_step, follow_up_at FROM suppliers WHERE {where} ORDER BY @@ -2662,12 +2701,14 @@ async def outreach(): status = request.args.get("status", "") country = request.args.get("country", "") search = request.args.get("search", "").strip() + follow_up = request.args.get("follow_up", "") page = max(1, int(request.args.get("page", "1") or "1")) pipeline = await get_outreach_pipeline() + follow_up_due = await get_follow_up_due_count() supplier_list = await get_outreach_suppliers( status=status or None, country=country or None, - search=search or None, page=page, + search=search or None, follow_up=follow_up or None, page=page, ) countries = await fetch_all( """SELECT DISTINCT country_code FROM suppliers @@ -2678,12 +2719,14 @@ async def outreach(): return await render_template( "admin/outreach.html", pipeline=pipeline, + follow_up_due=follow_up_due, suppliers=supplier_list, statuses=OUTREACH_STATUSES, countries=[c["country_code"] for c in countries], current_status=status, current_country=country, current_search=search, + current_follow_up=follow_up, page=page, ) @@ -2695,11 +2738,12 @@ async def outreach_results(): status = request.args.get("status", "") country = request.args.get("country", "") search = request.args.get("search", "").strip() + follow_up = request.args.get("follow_up", "") page = max(1, int(request.args.get("page", "1") or "1")) supplier_list = await get_outreach_suppliers( status=status or None, country=country or None, - search=search or None, page=page, + search=search or None, follow_up=follow_up or None, page=page, ) return await render_template( "admin/partials/outreach_results.html", suppliers=supplier_list, @@ -2755,6 +2799,33 @@ async def outreach_note(supplier_id: int): return note[:80] + ("…" if len(note) > 80 else "") if note else "" +@bp.route("/outreach//follow-up", methods=["POST"]) +@role_required("admin") +@csrf_protect +async def outreach_follow_up(supplier_id: int): + """HTMX: set or clear the follow_up_at date for a supplier, return the updated row.""" + supplier = await fetch_one( + "SELECT * FROM suppliers WHERE id = ? AND outreach_status IS NOT NULL", + (supplier_id,), + ) + if not supplier: + return Response("Not found", status=404) + + form = await request.form + follow_up_at_raw = form.get("follow_up_at", "").strip() + + # Accept YYYY-MM-DD or empty (to clear) + follow_up_at = follow_up_at_raw if follow_up_at_raw else None + + await execute( + "UPDATE suppliers SET follow_up_at = ? WHERE id = ?", + (follow_up_at, supplier_id), + ) + + updated = await fetch_one("SELECT * FROM suppliers WHERE id = ?", (supplier_id,)) + return await render_template("admin/partials/outreach_row.html", s=updated) + + @bp.route("/outreach/add-prospects", methods=["POST"]) @role_required("admin") @csrf_protect diff --git a/web/src/padelnomics/admin/templates/admin/outreach.html b/web/src/padelnomics/admin/templates/admin/outreach.html index 42ce4ca..aabc6a5 100644 --- a/web/src/padelnomics/admin/templates/admin/outreach.html +++ b/web/src/padelnomics/admin/templates/admin/outreach.html @@ -37,6 +37,17 @@ {% endfor %} + + {% if follow_up_due > 0 %} +
+ + ⏰ {{ follow_up_due }} follow-up{{ 's' if follow_up_due != 1 else '' }} due today + + Show them +
+ {% endif %} +
+ {% if current_follow_up %}{% endif %}
diff --git a/web/src/padelnomics/admin/templates/admin/partials/article_row.html b/web/src/padelnomics/admin/templates/admin/partials/article_row.html index 1e3b227..470cb13 100644 --- a/web/src/padelnomics/admin/templates/admin/partials/article_row.html +++ b/web/src/padelnomics/admin/templates/admin/partials/article_row.html @@ -9,6 +9,7 @@ {% else %} Draft {% endif %} + {% if a.noindex %}noindex{% endif %} {{ a.published_at[:10] if a.published_at else '-' }} {{ a.language | upper if a.language else '-' }} diff --git a/web/src/padelnomics/admin/templates/admin/partials/outreach_results.html b/web/src/padelnomics/admin/templates/admin/partials/outreach_results.html index ec450b3..adfea61 100644 --- a/web/src/padelnomics/admin/templates/admin/partials/outreach_results.html +++ b/web/src/padelnomics/admin/templates/admin/partials/outreach_results.html @@ -8,6 +8,7 @@ Status Step Last Contact + Follow-up Notes Actions diff --git a/web/src/padelnomics/admin/templates/admin/partials/outreach_row.html b/web/src/padelnomics/admin/templates/admin/partials/outreach_row.html index 052a92b..b6aacf3 100644 --- a/web/src/padelnomics/admin/templates/admin/partials/outreach_row.html +++ b/web/src/padelnomics/admin/templates/admin/partials/outreach_row.html @@ -35,6 +35,20 @@ — {% endif %} + + {# Follow-up date picker — submits on change, row swaps via HTMX #} + + + + + {# Inline note edit #}
data newer than articles

-

Health Checks

-

-

see Health section below

+

Noindex

+

{{ noindex_count }}

+

thin-data articles

diff --git a/web/src/padelnomics/admin/templates/admin/supplier_detail.html b/web/src/padelnomics/admin/templates/admin/supplier_detail.html index 0e9b69c..7cc4e24 100644 --- a/web/src/padelnomics/admin/templates/admin/supplier_detail.html +++ b/web/src/padelnomics/admin/templates/admin/supplier_detail.html @@ -143,6 +143,34 @@ {% endif %} + + {% if supplier.contact_email %} +
+

Activity Timeline

+ {% if timeline %} +
+ {% for entry in timeline %} +
+ + {% if entry.direction == 'sent' %}←{% else %}→{% endif %} + + + {{ entry.created_at[:10] if entry.created_at else '—' }} + + + {{ (entry.subject or '(no subject)')[:80] }} + + {{ entry.direction }} +
+ {% endfor %} +
+ {% else %} +

No email history yet.

+ {% endif %} +
+ {% endif %} +

Active Boosts

diff --git a/web/src/padelnomics/content/__init__.py b/web/src/padelnomics/content/__init__.py index 2368a14..faadcae 100644 --- a/web/src/padelnomics/content/__init__.py +++ b/web/src/padelnomics/content/__init__.py @@ -26,6 +26,14 @@ logger = logging.getLogger(__name__) TEMPLATES_DIR = Path(__file__).parent / "templates" BUILD_DIR = Path("data/content/_build") +# Threshold functions per template slug. +# Return True → article should be noindex (insufficient data for quality content). +NOINDEX_THRESHOLDS: dict = { + "city-pricing": lambda row: (row.get("venue_count") or 0) < 3, + "city-cost-de": lambda row: (row.get("data_confidence") or 0) < 1.0, + "country-overview": lambda row: (row.get("total_venues") or 0) < 5, +} + _REQUIRED_FRONTMATTER = { "name", "slug", "content_type", "data_table", "natural_key", "languages", "url_pattern", "title_pattern", @@ -499,25 +507,31 @@ async def generate_articles( md_dir.mkdir(parents=True, exist_ok=True) (md_dir / f"{article_slug}.md").write_text(body_md) + # Evaluate noindex threshold for this template + data row. + _threshold = NOINDEX_THRESHOLDS.get(slug) + should_noindex = 1 if _threshold and _threshold(row) else 0 + # Upsert article — keyed by (url_path, language). # Single statement: no SELECT round-trip, no per-row commit. await db.execute( """INSERT INTO articles (url_path, slug, title, meta_description, country, region, status, published_at, template_slug, language, date_modified, - seo_head, created_at) - VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?) + seo_head, noindex, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(url_path, language) DO UPDATE SET title = excluded.title, meta_description = excluded.meta_description, template_slug = excluded.template_slug, date_modified = excluded.date_modified, seo_head = excluded.seo_head, + noindex = excluded.noindex, updated_at = excluded.date_modified""", ( url_path, article_slug, title, meta_desc, row.get("country", ""), row.get("region", ""), - publish_dt, slug, lang, now_iso, seo_head, now_iso, + publish_dt, slug, lang, now_iso, seo_head, + should_noindex, now_iso, ), ) diff --git a/web/src/padelnomics/content/templates/article_detail.html b/web/src/padelnomics/content/templates/article_detail.html index 7df25ad..fefaa73 100644 --- a/web/src/padelnomics/content/templates/article_detail.html +++ b/web/src/padelnomics/content/templates/article_detail.html @@ -3,6 +3,7 @@ {% block title %}{{ article.title }} - {{ config.APP_NAME }}{% endblock %} {% block head %} +{% if article.noindex %}{% endif %} diff --git a/web/src/padelnomics/migrations/versions/0025_outreach_followup_and_noindex.py b/web/src/padelnomics/migrations/versions/0025_outreach_followup_and_noindex.py new file mode 100644 index 0000000..df14ff7 --- /dev/null +++ b/web/src/padelnomics/migrations/versions/0025_outreach_followup_and_noindex.py @@ -0,0 +1,15 @@ +"""Migration 0025: Add follow_up_at to suppliers + noindex to articles. + +follow_up_at: ISO date string (YYYY-MM-DD) for scheduled follow-up reminders. + NULL = no follow-up scheduled. + +noindex: 1 = search engines should not index this article (thin/insufficient data). + 0 = indexable (default). Set at generation time by NOINDEX_THRESHOLDS. +""" + + +def up(conn) -> None: + # Scheduled follow-up date for outreach pipeline suppliers. + conn.execute("ALTER TABLE suppliers ADD COLUMN follow_up_at TEXT DEFAULT NULL") + # Prevent indexing of articles with insufficient data. + conn.execute("ALTER TABLE articles ADD COLUMN noindex INTEGER NOT NULL DEFAULT 0") diff --git a/web/src/padelnomics/sitemap.py b/web/src/padelnomics/sitemap.py index 6103093..e98f78b 100644 --- a/web/src/padelnomics/sitemap.py +++ b/web/src/padelnomics/sitemap.py @@ -68,11 +68,12 @@ async def _generate_sitemap_xml(base_url: str) -> str: # Billing pricing — no lang prefix, no hreflang entries.append(_url_entry(f"{base}/billing/pricing", [])) - # Published articles — both lang variants with accurate lastmod + # Published articles — both lang variants with accurate lastmod. + # Exclude noindex articles (thin data) to keep sitemap signal-dense. articles = await fetch_all( """SELECT url_path, COALESCE(updated_at, published_at) AS lastmod FROM articles - WHERE status = 'published' AND published_at <= datetime('now') + WHERE status = 'published' AND noindex = 0 AND published_at <= datetime('now') ORDER BY published_at DESC LIMIT 25000""" ) diff --git a/web/tests/test_noindex.py b/web/tests/test_noindex.py new file mode 100644 index 0000000..89efb72 --- /dev/null +++ b/web/tests/test_noindex.py @@ -0,0 +1,196 @@ +""" +Tests for pSEO article noindex feature. + +Covers: +- NOINDEX_THRESHOLDS: lambda functions evaluate correctly per template +- Sitemap excludes articles with noindex=1 +- Article detail page emits for noindex articles +- Article detail page has no robots meta tag for indexable articles +""" +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +import pytest + +from padelnomics import core +from padelnomics.content import NOINDEX_THRESHOLDS + + +# ── Threshold unit tests ───────────────────────────────────────────────────── + + +class TestNoindexThresholds: + def test_city_pricing_low_venue_count_is_noindex(self): + check = NOINDEX_THRESHOLDS["city-pricing"] + assert check({"venue_count": 0}) is True + assert check({"venue_count": 1}) is True + assert check({"venue_count": 2}) is True + + def test_city_pricing_sufficient_venues_is_indexable(self): + check = NOINDEX_THRESHOLDS["city-pricing"] + assert check({"venue_count": 3}) is False + assert check({"venue_count": 10}) is False + + def test_city_pricing_missing_venue_count_treated_as_zero(self): + check = NOINDEX_THRESHOLDS["city-pricing"] + assert check({}) is True + assert check({"venue_count": None}) is True + + def test_city_cost_de_partial_data_is_noindex(self): + check = NOINDEX_THRESHOLDS["city-cost-de"] + assert check({"data_confidence": 0.0}) is True + assert check({"data_confidence": 0.5}) is True + assert check({"data_confidence": 0.99}) is True + + def test_city_cost_de_full_confidence_is_indexable(self): + check = NOINDEX_THRESHOLDS["city-cost-de"] + assert check({"data_confidence": 1.0}) is False + + def test_city_cost_de_missing_confidence_is_noindex(self): + check = NOINDEX_THRESHOLDS["city-cost-de"] + assert check({}) is True + assert check({"data_confidence": None}) is True + + def test_country_overview_low_venues_is_noindex(self): + check = NOINDEX_THRESHOLDS["country-overview"] + assert check({"total_venues": 0}) is True + assert check({"total_venues": 4}) is True + + def test_country_overview_sufficient_venues_is_indexable(self): + check = NOINDEX_THRESHOLDS["country-overview"] + assert check({"total_venues": 5}) is False + assert check({"total_venues": 100}) is False + + def test_unknown_template_slug_has_no_threshold(self): + assert "manual" not in NOINDEX_THRESHOLDS + assert "unknown-template" not in NOINDEX_THRESHOLDS + + +# ── Sitemap exclusion ──────────────────────────────────────────────────────── + + +async def _insert_article( + db, + url_path: str = "/markets/de/berlin", + title: str = "Test Article", + language: str = "en", + noindex: int = 0, +) -> int: + """Insert a published article row and return its id.""" + # Use a past published_at in SQLite-compatible format (space separator, no tz). + # SQLite's datetime('now') returns "YYYY-MM-DD HH:MM:SS" with a space. + # ISO format with T is lexicographically AFTER the space format for the + # same instant, so current-time ISO strings fail the <= datetime('now') check. + published_at = "2020-01-01 08:00:00" + created_at = datetime.now(UTC).isoformat() + async with db.execute( + """INSERT INTO articles + (url_path, slug, title, meta_description, status, published_at, + template_slug, language, noindex, created_at) + VALUES (?, ?, ?, '', 'published', ?, 'city-pricing', ?, ?, ?)""", + (url_path, f"slug-{url_path.replace('/', '-')}", title, + published_at, language, noindex, created_at), + ) as cursor: + article_id = cursor.lastrowid + await db.commit() + return article_id + + +class TestSitemapNoindex: + async def test_indexable_article_in_sitemap(self, client, db): + """Article with noindex=0 should appear in sitemap.""" + await _insert_article(db, url_path="/markets/en/berlin", noindex=0) + resp = await client.get("/sitemap.xml") + xml = (await resp.data).decode() + assert "/markets/en/berlin" in xml + + async def test_noindex_article_excluded_from_sitemap(self, client, db): + """Article with noindex=1 must NOT appear in sitemap.""" + await _insert_article(db, url_path="/markets/en/thin-city", noindex=1) + resp = await client.get("/sitemap.xml") + xml = (await resp.data).decode() + assert "/markets/en/thin-city" not in xml + + async def test_mixed_articles_only_indexable_in_sitemap(self, client, db): + """Only indexable articles appear; noindex articles are silently dropped.""" + await _insert_article(db, url_path="/markets/en/good-city", noindex=0) + await _insert_article(db, url_path="/markets/en/bad-city", noindex=1) + + resp = await client.get("/sitemap.xml") + xml = (await resp.data).decode() + assert "good-city" in xml + assert "bad-city" not in xml + + +# ── Article detail robots meta tag ────────────────────────────────────────── + + +class TestArticleDetailRobotsTag: + """ + Test that the article detail template emits (or omits) the robots meta tag. + We test via the content blueprint's article route. + + Routes.py imports BUILD_DIR from content/__init__.py at module load time, so + we must patch padelnomics.content.routes.BUILD_DIR (the local binding), not + padelnomics.content.BUILD_DIR. + """ + + async def test_noindex_article_has_robots_meta(self, client, db, tmp_path, monkeypatch): + """Article with noindex=1 → in HTML.""" + import padelnomics.content.routes as routes_mod + + build_dir = tmp_path / "en" + build_dir.mkdir(parents=True) + + url_path = "/markets/noindex-test" + slug = "city-pricing-en-noindex-test" + (build_dir / f"{slug}.html").write_text("

Article body

") + + monkeypatch.setattr(routes_mod, "BUILD_DIR", tmp_path) + + # Use past published_at in SQLite space-separator format + async with db.execute( + """INSERT INTO articles + (url_path, slug, title, meta_description, status, published_at, + template_slug, language, noindex, created_at) + VALUES (?, ?, 'Noindex Test', '', 'published', '2020-01-01 08:00:00', + 'city-pricing', 'en', 1, datetime('now'))""", + (url_path, slug), + ) as cursor: + pass + await db.commit() + + resp = await client.get(f"/en{url_path}") + assert resp.status_code == 200 + html = (await resp.data).decode() + assert 'name="robots"' in html + assert "noindex" in html + + async def test_indexable_article_has_no_robots_meta(self, client, db, tmp_path, monkeypatch): + """Article with noindex=0 → no robots meta tag in HTML.""" + import padelnomics.content.routes as routes_mod + + build_dir = tmp_path / "en" + build_dir.mkdir(parents=True) + + url_path = "/markets/indexable-test" + slug = "city-pricing-en-indexable-test" + (build_dir / f"{slug}.html").write_text("

Article body

") + + monkeypatch.setattr(routes_mod, "BUILD_DIR", tmp_path) + + async with db.execute( + """INSERT INTO articles + (url_path, slug, title, meta_description, status, published_at, + template_slug, language, noindex, created_at) + VALUES (?, ?, 'Indexable Test', '', 'published', '2020-01-01 08:00:00', + 'city-pricing', 'en', 0, datetime('now'))""", + (url_path, slug), + ) as cursor: + pass + await db.commit() + + resp = await client.get(f"/en{url_path}") + assert resp.status_code == 200 + html = (await resp.data).decode() + assert 'content="noindex' not in html diff --git a/web/tests/test_outreach.py b/web/tests/test_outreach.py index 57377df..e72bd8a 100644 --- a/web/tests/test_outreach.py +++ b/web/tests/test_outreach.py @@ -22,6 +22,7 @@ from quart.datastructures import FileStorage from padelnomics import core from padelnomics.admin.routes import ( OUTREACH_STATUSES, + get_follow_up_due_count, get_outreach_pipeline, get_outreach_suppliers, ) @@ -74,6 +75,7 @@ async def _insert_supplier( outreach_notes: str = None, outreach_sequence_step: int = 0, last_contacted_at: str = None, + follow_up_at: str = None, ) -> int: """Insert a supplier row and return its id.""" now = datetime.now(UTC).isoformat() @@ -82,12 +84,12 @@ async def _insert_supplier( """INSERT INTO suppliers (name, slug, country_code, region, category, tier, contact_email, outreach_status, outreach_notes, outreach_sequence_step, - last_contacted_at, created_at) + last_contacted_at, follow_up_at, created_at) VALUES (?, ?, ?, 'Europe', 'construction', 'free', - ?, ?, ?, ?, ?, ?)""", + ?, ?, ?, ?, ?, ?, ?)""", (name, slug, country_code, contact_email, outreach_status, outreach_notes, outreach_sequence_step, - last_contacted_at, now), + last_contacted_at, follow_up_at, now), ) as cursor: supplier_id = cursor.lastrowid await db.commit() @@ -617,6 +619,216 @@ class TestComposePipelineUpdate: assert row["outreach_sequence_step"] == 0 # unchanged +# ── Follow-up Scheduling ───────────────────────────────────────────────────── + + +class TestFollowUpRoute: + async def test_follow_up_set(self, admin_client, db): + """POST a date → follow_up_at saved, updated row returned.""" + supplier_id = await _insert_supplier(db, outreach_status="prospect") + resp = await admin_client.post( + f"/admin/outreach/{supplier_id}/follow-up", + form={"follow_up_at": "2026-03-01", "csrf_token": _TEST_CSRF}, + ) + assert resp.status_code == 200 + + row = await core.fetch_one( + "SELECT follow_up_at FROM suppliers WHERE id = ?", (supplier_id,) + ) + assert row["follow_up_at"] == "2026-03-01" + + async def test_follow_up_set_returns_row_html(self, admin_client, db): + """Response should contain the supplier row (for HTMX outerHTML swap).""" + supplier_id = await _insert_supplier(db, outreach_status="contacted") + resp = await admin_client.post( + f"/admin/outreach/{supplier_id}/follow-up", + form={"follow_up_at": "2026-04-15", "csrf_token": _TEST_CSRF}, + ) + html = (await resp.data).decode() + assert str(supplier_id) in html + + async def test_follow_up_clear(self, admin_client, db): + """POST empty date → follow_up_at set to NULL.""" + supplier_id = await _insert_supplier( + db, outreach_status="contacted", follow_up_at="2026-03-01" + ) + await admin_client.post( + f"/admin/outreach/{supplier_id}/follow-up", + form={"follow_up_at": "", "csrf_token": _TEST_CSRF}, + ) + row = await core.fetch_one( + "SELECT follow_up_at FROM suppliers WHERE id = ?", (supplier_id,) + ) + assert row["follow_up_at"] is None + + async def test_follow_up_404_for_non_pipeline_supplier(self, admin_client, db): + """Supplier not in outreach pipeline → 404.""" + supplier_id = await _insert_supplier(db, outreach_status=None) + resp = await admin_client.post( + f"/admin/outreach/{supplier_id}/follow-up", + form={"follow_up_at": "2026-03-01", "csrf_token": _TEST_CSRF}, + ) + assert resp.status_code == 404 + + +class TestFollowUpDueCount: + async def test_counts_only_due_and_overdue(self, db): + """Only suppliers with follow_up_at <= today are counted.""" + # Past (overdue) + await _insert_supplier(db, name="Past", outreach_status="contacted", + follow_up_at="2020-01-01") + # Future (not yet due) + await _insert_supplier(db, name="Future", outreach_status="contacted", + follow_up_at="2099-12-31") + # No follow-up scheduled + await _insert_supplier(db, name="None", outreach_status="prospect", + follow_up_at=None) + count = await get_follow_up_due_count() + assert count == 1 + + async def test_zero_when_no_follow_ups(self, db): + await _insert_supplier(db, outreach_status="prospect") + count = await get_follow_up_due_count() + assert count == 0 + + async def test_follow_up_due_filter(self, db): + """?follow_up=due filter returns only overdue suppliers.""" + await _insert_supplier(db, name="Overdue", outreach_status="contacted", + follow_up_at="2020-06-01") + await _insert_supplier(db, name="NotYet", outreach_status="contacted", + follow_up_at="2099-01-01") + + due = await get_outreach_suppliers(follow_up="due") + names = [s["name"] for s in due] + assert "Overdue" in names + assert "NotYet" not in names + + async def test_follow_up_set_filter(self, db): + """?follow_up=set returns suppliers with any follow-up date.""" + await _insert_supplier(db, name="HasDate", outreach_status="contacted", + follow_up_at="2099-01-01") + await _insert_supplier(db, name="NoDate", outreach_status="contacted", + follow_up_at=None) + + with_date = await get_outreach_suppliers(follow_up="set") + names = [s["name"] for s in with_date] + assert "HasDate" in names + assert "NoDate" not in names + + async def test_outreach_dashboard_shows_follow_up_banner(self, admin_client, db): + """Banner visible when there's at least one due follow-up.""" + await _insert_supplier(db, name="DueNow", outreach_status="prospect", + follow_up_at="2020-01-01") + resp = await admin_client.get("/admin/outreach") + html = (await resp.data).decode() + assert "follow-up" in html.lower() or "Follow-up" in html + + async def test_outreach_results_follow_up_filter(self, admin_client, db): + """follow_up=due querystring filters results partial.""" + await _insert_supplier(db, name="DueSupplier", outreach_status="contacted", + follow_up_at="2020-01-01") + await _insert_supplier(db, name="FutureSupplier", outreach_status="contacted", + follow_up_at="2099-01-01") + resp = await admin_client.get("/admin/outreach/results?follow_up=due") + html = (await resp.data).decode() + assert "DueSupplier" in html + assert "FutureSupplier" not in html + + +# ── Activity Timeline ───────────────────────────────────────────────────────── + + +async def _insert_email_log(db, to_addr: str, subject: str, email_type: str = "outreach") -> int: + """Insert a sent email entry into email_log.""" + now = datetime.now(UTC).isoformat() + async with db.execute( + """INSERT INTO email_log (from_addr, to_addr, subject, email_type, created_at) + VALUES (?, ?, ?, ?, ?)""", + ("hello@hello.padelnomics.io", to_addr, subject, email_type, now), + ) as cursor: + row_id = cursor.lastrowid + await db.commit() + return row_id + + +async def _insert_inbound_email(db, from_addr: str, subject: str) -> int: + """Insert a received email entry into inbound_emails.""" + now = datetime.now(UTC).isoformat() + async with db.execute( + """INSERT INTO inbound_emails + (resend_id, from_addr, to_addr, subject, received_at, created_at) + VALUES (?, ?, ?, ?, ?, ?)""", + (f"test-{now}", from_addr, "inbox@hello.padelnomics.io", subject, now, now), + ) as cursor: + row_id = cursor.lastrowid + await db.commit() + return row_id + + +class TestActivityTimeline: + async def test_timeline_shows_sent_emails(self, admin_client, db): + """Sent outreach emails visible in supplier detail timeline.""" + supplier_id = await _insert_supplier( + db, name="Timeline Test", contact_email="timeline@example.com", + outreach_status="contacted", + ) + await _insert_email_log(db, "timeline@example.com", "Introduction email") + + resp = await admin_client.get(f"/admin/suppliers/{supplier_id}") + assert resp.status_code == 200 + html = (await resp.data).decode() + assert "Introduction email" in html + + async def test_timeline_shows_received_emails(self, admin_client, db): + """Received emails visible in supplier detail timeline.""" + supplier_id = await _insert_supplier( + db, name="Inbound Test", contact_email="inbound@example.com", + outreach_status="replied", + ) + await _insert_inbound_email(db, "inbound@example.com", "Re: Partnership") + + resp = await admin_client.get(f"/admin/suppliers/{supplier_id}") + assert resp.status_code == 200 + html = (await resp.data).decode() + assert "Re: Partnership" in html + + async def test_timeline_empty_state(self, admin_client, db): + """Supplier with no emails shows empty state message.""" + supplier_id = await _insert_supplier( + db, name="No Emails", contact_email="noemail@example.com", + ) + resp = await admin_client.get(f"/admin/suppliers/{supplier_id}") + assert resp.status_code == 200 + html = (await resp.data).decode() + assert "No email history" in html + + async def test_timeline_excludes_non_outreach_sent_emails(self, admin_client, db): + """Transactional sent emails (magic links etc.) not shown in timeline.""" + supplier_id = await _insert_supplier( + db, name="Transact Test", contact_email="trans@example.com", + ) + await _insert_email_log( + db, "trans@example.com", "Magic link", email_type="magic_link" + ) + resp = await admin_client.get(f"/admin/suppliers/{supplier_id}") + html = (await resp.data).decode() + assert "Magic link" not in html + + async def test_timeline_not_shown_when_no_contact_email(self, admin_client, db): + """Timeline section should still load (empty) when contact_email is NULL.""" + now = datetime.now(UTC).isoformat() + async with db.execute( + """INSERT INTO suppliers (name, slug, tier, country_code, region, category, created_at) + VALUES ('No Contact', 'no-contact', 'free', 'XX', 'Europe', 'construction', ?)""", + (now,), + ) as cursor: + supplier_id = cursor.lastrowid + await db.commit() + + resp = await admin_client.get(f"/admin/suppliers/{supplier_id}") + assert resp.status_code == 200 + + # ── CSV writer helper (avoids importing DictWriter at module level) ──────────── import csv as _csv_module