diff --git a/.gitignore b/.gitignore
index f56d31d..7cab796 100644
--- a/.gitignore
+++ b/.gitignore
@@ -176,3 +176,10 @@ cython_debug/
# PyPI configuration file
.pypirc
+.claude/worktrees/
+
+*.duckdb
+*.duckdb.wal
+data/
+.claude/worktrees/
+
diff --git a/web/.copier-answers.yml b/web/.copier-answers.yml
index 840dfc8..e0d2ce9 100644
--- a/web/.copier-answers.yml
+++ b/web/.copier-answers.yml
@@ -1,10 +1,16 @@
# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
-_commit: v0.4.0
+_commit: v0.17.0
_src_path: git@gitlab.com:deemanone/materia_saas_boilerplate.master.git
author_email: hendrik@beanflows.coffee
author_name: Hendrik Deeman
base_url: https://beanflows.coffee
+business_model: saas
description: Commodity analytics for coffee traders
+enable_cms: true
+enable_daas: true
+enable_directory: false
+enable_i18n: false
+enable_leads: false
payment_provider: paddle
project_name: BeanFlows
project_slug: beanflows
diff --git a/web/scripts/seed_cms_coffee.py b/web/scripts/seed_cms_coffee.py
new file mode 100644
index 0000000..4ef33df
--- /dev/null
+++ b/web/scripts/seed_cms_coffee.py
@@ -0,0 +1,375 @@
+#!/usr/bin/env python3
+"""
+seed_cms_coffee.py — Seed coffee commodity CMS article templates.
+
+Creates:
+ 1. Article templates (Jinja2 body_template + URL/title patterns)
+ 2. Template data rows (one per country/commodity/year combo)
+ pulled from the DuckDB serving layer when available.
+
+Usage (from web/ directory):
+ uv run python scripts/seed_cms_coffee.py [--db data/app.db] [--dry-run]
+
+After running this, go to /admin/cms to bulk-generate the articles.
+"""
+
+import argparse
+import json
+import os
+import sqlite3
+import sys
+from pathlib import Path
+
+# ── Config ────────────────────────────────────────────────────────────────────
+
+DB_DEFAULT = "data/app.db"
+SERVING_DB = os.getenv("SERVING_DUCKDB_PATH", "")
+
+
+# ── Article templates ─────────────────────────────────────────────────────────
+
+TEMPLATES = [
+ {
+ "name": "coffee-country-overview",
+ "url_pattern": "/coffee/{{ country_slug }}",
+ "title_pattern": "{{ country_name }} Coffee Production & Trade — BeanFlows",
+ "meta_description_pattern": (
+ "USDA PSD supply/demand data for {{ country_name }} coffee: "
+ "production, exports, imports, ending stocks, and market trends."
+ ),
+ "body_template": """\
+
{{ country_name }} Coffee Overview
+
+
+ {{ country_name }} is {% if rank <= 5 %}one of the world's top coffee-producing nations
+ {% else %}a notable player in the global coffee market{% endif %}.
+ In {{ latest_year }}, total production reached
+ {{ "{:,}".format(production_bags|int) }} 60-kg bags .
+
+
+Supply & Demand Snapshot ({{ latest_year }})
+
+
+ Metric Value (60-kg bags)
+
+
+ Production {{ "{:,}".format(production_bags|int) }}
+ Exports {{ "{:,}".format(exports_bags|int) }}
+ Domestic Consumption {{ "{:,}".format(domestic_consumption_bags|int) }}
+ Ending Stocks {{ "{:,}".format(ending_stocks_bags|int) }}
+
+
+
+Production Trend
+
+ Over the past decade, {{ country_name }}'s coffee output has shown
+ {% if production_trend == 'up' %}a rising trend
+ {% elif production_trend == 'down' %}a declining trend
+ {% else %}relatively stable production
+ {% endif %}.
+ Year-on-year change in {{ latest_year }}: {{ production_yoy_pct }}% .
+
+
+Key Export Markets
+
+ {{ country_name }} primarily exports to international commodity markets,
+ with volumes settled against the ICE Coffee C futures contract (KC=F).
+ Track live price data and warehouse stocks on the
+ BeanFlows positioning dashboard .
+
+
+Data source: USDA PSD Online, updated {{ data_vintage }}.
+""",
+ },
+ {
+ "name": "coffee-global-market-year",
+ "url_pattern": "/coffee/market/{{ market_year }}",
+ "title_pattern": "Global Coffee Market {{ market_year }} — Supply, Demand & Stocks",
+ "meta_description_pattern": (
+ "Global coffee supply and demand balance for {{ market_year }}: "
+ "USDA PSD production, consumption, trade, and ending stocks data."
+ ),
+ "body_template": """\
+Global Coffee Market {{ market_year }}
+
+
+ The {{ market_year }} global coffee marketing year ran from
+ October {{ market_year|int - 1 }} through September {{ market_year }}.
+ World production totalled
+ {{ "{:,}".format(world_production_bags|int) }} million 60-kg bags .
+
+
+World Supply & Demand Balance
+
+
+ Metric Million 60-kg Bags
+
+
+ Opening Stocks {{ "%.1f"|format(beginning_stocks_m|float) }}
+ Production {{ "%.1f"|format(production_m|float) }}
+ Total Supply {{ "%.1f"|format(total_supply_m|float) }}
+ Consumption {{ "%.1f"|format(consumption_m|float) }}
+ Ending Stocks {{ "%.1f"|format(ending_stocks_m|float) }}
+ Stock-to-Use Ratio {{ "%.1f"|format(stu_pct|float) }}%
+
+
+
+Supply/Demand Balance
+
+ The {{ market_year }} marketing year ended with a
+ {% if balance >= 0 %}surplus of {{ "%.1f"|format(balance|float) }}M bags
+ {% else %}deficit of {{ "%.1f"|format((balance|float)|abs) }}M bags
+ {% endif %}.
+ The stock-to-use ratio of {{ "%.1f"|format(stu_pct|float) }}% indicates
+ {% if stu_pct|float > 25 %}comfortable{% elif stu_pct|float > 18 %}adequate{% else %}tight{% endif %}
+ global supply conditions.
+
+
+
+ Explore live supply & demand charts and price data on
+ BeanFlows Supply Dashboard .
+
+
+Data source: USDA PSD Online, updated {{ data_vintage }}.
+""",
+ },
+]
+
+
+# ── Data generation ───────────────────────────────────────────────────────────
+
+def fetch_country_data_from_duckdb() -> list[dict]:
+ """Pull top coffee-producing countries from DuckDB serving layer."""
+ if not SERVING_DB or not Path(SERVING_DB).exists():
+ print(f" Serving DB not found at {SERVING_DB!r} — using placeholder countries")
+ return []
+
+ try:
+ import duckdb
+ conn = duckdb.connect(SERVING_DB, read_only=True)
+ rows = conn.execute("""
+ WITH latest AS (
+ SELECT MAX(market_year) AS max_year
+ FROM serving.commodity_metrics
+ WHERE commodity_code = 711100 AND country_code IS NOT NULL
+ ),
+ ranked AS (
+ SELECT country_name, country_code, market_year,
+ production * 1000 AS production_bags,
+ exports * 1000 AS exports_bags,
+ domestic_consumption * 1000 AS domestic_consumption_bags,
+ ending_stocks * 1000 AS ending_stocks_bags,
+ production_yoy_pct,
+ ROW_NUMBER() OVER (ORDER BY production DESC) AS rank
+ FROM serving.commodity_metrics, latest
+ WHERE commodity_code = 711100
+ AND country_code IS NOT NULL
+ AND market_year = latest.max_year
+ AND production > 0
+ )
+ SELECT * FROM ranked LIMIT 30
+ """).fetchall()
+ cols = [d[0] for d in conn.execute("""
+ WITH latest AS (SELECT MAX(market_year) AS max_year FROM serving.commodity_metrics
+ WHERE commodity_code = 711100 AND country_code IS NOT NULL)
+ SELECT country_name, country_code, market_year, production * 1000,
+ exports * 1000, domestic_consumption * 1000, ending_stocks * 1000,
+ production_yoy_pct, 1 FROM serving.commodity_metrics, latest LIMIT 0
+ """).description or []]
+ return [dict(zip(["country_name","country_code","market_year","production_bags",
+ "exports_bags","domestic_consumption_bags","ending_stocks_bags",
+ "production_yoy_pct","rank"], row)) for row in rows]
+ except Exception as e:
+ print(f" DuckDB error: {e} — using placeholder countries")
+ return []
+
+
+def fetch_global_year_data_from_duckdb() -> list[dict]:
+ """Pull global supply/demand summary per market year."""
+ if not SERVING_DB or not Path(SERVING_DB).exists():
+ return []
+
+ try:
+ import duckdb
+ conn = duckdb.connect(SERVING_DB, read_only=True)
+ rows = conn.execute("""
+ SELECT market_year,
+ beginning_stocks * 1000 AS beginning_stocks_bags,
+ production * 1000 AS world_production_bags,
+ total_supply * 1000 AS total_supply_bags,
+ domestic_consumption * 1000 AS consumption_bags,
+ ending_stocks * 1000 AS ending_stocks_bags,
+ production / NULLIF(total_distribution, 0) * 1000 AS beginning_stocks_m,
+ production AS production_m,
+ total_supply AS total_supply_m,
+ domestic_consumption AS consumption_m,
+ ending_stocks AS ending_stocks_m,
+ supply_demand_balance AS balance,
+ stock_to_use_ratio_pct AS stu_pct
+ FROM serving.commodity_metrics
+ WHERE commodity_code = 711100 AND country_name = 'Global'
+ ORDER BY market_year DESC
+ LIMIT 10
+ """).fetchall()
+ cols = ["market_year","beginning_stocks_bags","world_production_bags",
+ "total_supply_bags","consumption_bags","ending_stocks_bags",
+ "beginning_stocks_m","production_m","total_supply_m","consumption_m",
+ "ending_stocks_m","balance","stu_pct"]
+ return [dict(zip(cols, row)) for row in rows]
+ except Exception as e:
+ print(f" DuckDB error (global): {e}")
+ return []
+
+
+PLACEHOLDER_COUNTRIES = [
+ {"country_name": "Brazil", "country_code": "BR", "rank": 1},
+ {"country_name": "Vietnam", "country_code": "VN", "rank": 2},
+ {"country_name": "Colombia", "country_code": "CO", "rank": 3},
+ {"country_name": "Indonesia", "country_code": "ID", "rank": 4},
+ {"country_name": "Ethiopia", "country_code": "ET", "rank": 5},
+ {"country_name": "Honduras", "country_code": "HN", "rank": 6},
+ {"country_name": "India", "country_code": "IN", "rank": 7},
+ {"country_name": "Uganda", "country_code": "UG", "rank": 8},
+ {"country_name": "Mexico", "country_code": "MX", "rank": 9},
+ {"country_name": "Peru", "country_code": "PE", "rank": 10},
+]
+
+
+def slug(name: str) -> str:
+ return name.lower().replace(" ", "-").replace(",", "").replace("'", "")
+
+
+# ── Main ──────────────────────────────────────────────────────────────────────
+
+def run(db_path: str, dry_run: bool = False):
+ conn = sqlite3.connect(db_path)
+ conn.row_factory = sqlite3.Row
+
+ now = __import__("datetime").datetime.utcnow().isoformat()
+ data_vintage = __import__("datetime").date.today().strftime("%B %Y")
+
+ inserted_templates = 0
+ inserted_data_rows = 0
+
+ for tmpl in TEMPLATES:
+ existing = conn.execute(
+ "SELECT id FROM article_templates WHERE name = ?", (tmpl["name"],)
+ ).fetchone()
+
+ if existing:
+ tmpl_id = existing["id"]
+ print(f" Template '{tmpl['name']}' already exists (id={tmpl_id})")
+ else:
+ if dry_run:
+ print(f" [dry-run] Would insert template: {tmpl['name']}")
+ tmpl_id = -1
+ else:
+ cursor = conn.execute(
+ """INSERT INTO article_templates
+ (name, slug, url_pattern, title_pattern, meta_description_pattern,
+ body_template, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
+ (tmpl["name"], tmpl["name"], tmpl["url_pattern"], tmpl["title_pattern"],
+ tmpl["meta_description_pattern"], tmpl["body_template"], now),
+ )
+ tmpl_id = cursor.lastrowid
+ inserted_templates += 1
+ print(f" Inserted template: {tmpl['name']} (id={tmpl_id})")
+
+ # Seed data rows per template
+ if tmpl["name"] == "coffee-country-overview":
+ countries = fetch_country_data_from_duckdb() or [
+ {**c, "latest_year": 2024, "production_bags": 0,
+ "exports_bags": 0, "domestic_consumption_bags": 0,
+ "ending_stocks_bags": 0, "production_yoy_pct": 0,
+ "production_trend": "stable"}
+ for c in PLACEHOLDER_COUNTRIES
+ ]
+ for c in countries:
+ country_slug = slug(c["country_name"])
+ data = {
+ "country_name": c["country_name"],
+ "country_code": c.get("country_code", ""),
+ "country_slug": country_slug,
+ "latest_year": c.get("market_year", 2024),
+ "production_bags": c.get("production_bags", 0),
+ "exports_bags": c.get("exports_bags", 0),
+ "domestic_consumption_bags": c.get("domestic_consumption_bags", 0),
+ "ending_stocks_bags": c.get("ending_stocks_bags", 0),
+ "production_yoy_pct": round(c.get("production_yoy_pct") or 0, 1),
+ "production_trend": (
+ "up" if (c.get("production_yoy_pct") or 0) > 2
+ else "down" if (c.get("production_yoy_pct") or 0) < -2
+ else "stable"
+ ),
+ "rank": c.get("rank", 99),
+ "data_vintage": data_vintage,
+ }
+ exists = conn.execute(
+ "SELECT id FROM template_data WHERE template_id = ? "
+ "AND json_extract(data_json, '$.country_code') = ?",
+ (tmpl_id, c.get("country_code", "")),
+ ).fetchone()
+ if not exists and not dry_run and tmpl_id > 0:
+ conn.execute(
+ "INSERT INTO template_data (template_id, data_json, created_at) VALUES (?, ?, ?)",
+ (tmpl_id, json.dumps(data), now),
+ )
+ inserted_data_rows += 1
+ elif dry_run:
+ print(f" [dry-run] Would insert data row: {c['country_name']}")
+
+ elif tmpl["name"] == "coffee-global-market-year":
+ years_data = fetch_global_year_data_from_duckdb()
+ if not years_data:
+ years_data = [{"market_year": y} for y in range(2020, 2025)]
+
+ for y in years_data:
+ data = {
+ "market_year": y["market_year"],
+ "world_production_bags": y.get("world_production_bags", 0),
+ "beginning_stocks_m": round(y.get("beginning_stocks_m") or 0, 1),
+ "production_m": round(y.get("production_m") or 0, 1),
+ "total_supply_m": round(y.get("total_supply_m") or 0, 1),
+ "consumption_m": round(y.get("consumption_m") or 0, 1),
+ "ending_stocks_m": round(y.get("ending_stocks_m") or 0, 1),
+ "balance": round(y.get("balance") or 0, 2),
+ "stu_pct": round(y.get("stu_pct") or 0, 1),
+ "data_vintage": data_vintage,
+ }
+ exists = conn.execute(
+ "SELECT id FROM template_data WHERE template_id = ? "
+ "AND json_extract(data_json, '$.market_year') = ?",
+ (tmpl_id, y["market_year"]),
+ ).fetchone()
+ if not exists and not dry_run and tmpl_id > 0:
+ conn.execute(
+ "INSERT INTO template_data (template_id, data_json, created_at) VALUES (?, ?, ?)",
+ (tmpl_id, json.dumps(data), now),
+ )
+ inserted_data_rows += 1
+ elif dry_run:
+ print(f" [dry-run] Would insert data row: market_year={y['market_year']}")
+
+ if not dry_run:
+ conn.commit()
+ conn.close()
+
+ print(f"\nDone — inserted {inserted_templates} templates, {inserted_data_rows} data rows.")
+ if not dry_run:
+ print("Next: go to /admin/cms → pSEO Templates → Bulk Generate")
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Seed coffee CMS templates")
+ parser.add_argument("--db", default=DB_DEFAULT, help=f"SQLite DB path (default: {DB_DEFAULT})")
+ parser.add_argument("--dry-run", action="store_true", help="Print what would be inserted, don't write")
+ args = parser.parse_args()
+
+ db = Path(args.db)
+ if not db.exists():
+ print(f"DB not found at {db}. Run migrations first: uv run python -m beanflows.migrations.migrate")
+ sys.exit(1)
+
+ print(f"Seeding coffee CMS content into {db}...")
+ run(str(db), dry_run=args.dry_run)
diff --git a/web/src/beanflows/admin/cms_routes.py b/web/src/beanflows/admin/cms_routes.py
new file mode 100644
index 0000000..b19a5b4
--- /dev/null
+++ b/web/src/beanflows/admin/cms_routes.py
@@ -0,0 +1,368 @@
+"""
+CMS / pSEO admin: article management, template CRUD, programmatic generation.
+"""
+import json
+from datetime import datetime
+from functools import wraps
+from pathlib import Path
+
+from quart import Blueprint, flash, g, redirect, render_template, request, url_for
+
+from ..core import execute, fetch_all, fetch_one, csrf_protect
+
+bp = Blueprint(
+ "cms",
+ __name__,
+ template_folder=str(Path(__file__).parent / "templates"),
+ url_prefix="/admin/cms",
+)
+
+
+def admin_required(f):
+ @wraps(f)
+ async def decorated(*args, **kwargs):
+ if "admin" not in (g.get("user") or {}).get("roles", []):
+ await flash("Admin access required.", "error")
+ from quart import redirect as _redirect, url_for as _url_for
+ return _redirect(_url_for("auth.login"))
+ return await f(*args, **kwargs)
+ return decorated
+
+
+# =============================================================================
+# Article helpers
+# =============================================================================
+
+async def list_articles(limit: int = 200) -> list[dict]:
+ return await fetch_all(
+ """SELECT a.*, at.name AS template_name
+ FROM articles a
+ LEFT JOIN template_data td ON td.id = a.template_data_id
+ LEFT JOIN article_templates at ON at.id = td.template_id
+ ORDER BY a.created_at DESC LIMIT ?""",
+ (limit,),
+ )
+
+
+async def get_article(article_id: int) -> dict | None:
+ return await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
+
+
+async def list_article_templates() -> list[dict]:
+ return await fetch_all(
+ """SELECT at.*, COUNT(td.id) AS data_count,
+ COUNT(td.article_id) AS generated_count
+ FROM article_templates at
+ LEFT JOIN template_data td ON td.template_id = at.id
+ GROUP BY at.id ORDER BY at.created_at DESC"""
+ )
+
+
+async def get_article_template(template_id: int) -> dict | None:
+ return await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
+
+
+async def list_template_data(template_id: int) -> list[dict]:
+ return await fetch_all(
+ "SELECT * FROM template_data WHERE template_id = ? ORDER BY id",
+ (template_id,),
+ )
+
+
+async def generate_article_from_data(data_row: dict, tmpl: dict) -> int | None:
+ """Generate (or regenerate) a single article from a template_data row."""
+ from jinja2 import Environment, BaseLoader
+
+ try:
+ data = json.loads(data_row["data_json"])
+ except (json.JSONDecodeError, KeyError):
+ return None
+
+ env = Environment(loader=BaseLoader())
+
+ def _render(pattern: str) -> str:
+ try:
+ return env.from_string(pattern).render(**data)
+ except Exception:
+ return pattern
+
+ url_path = "/" + _render(tmpl["url_pattern"]).strip("/")
+ slug = url_path.strip("/").replace("/", "-")
+ title = _render(tmpl["title_pattern"])
+ meta_description = _render(tmpl.get("meta_description_pattern") or "")
+ now = datetime.utcnow().isoformat()
+
+ existing = await fetch_one("SELECT id FROM articles WHERE url_path = ?", (url_path,))
+ if existing:
+ article_id = existing["id"]
+ await execute(
+ """UPDATE articles
+ SET title = ?, meta_description = ?, status = 'published',
+ published_at = COALESCE(published_at, ?),
+ template_data_id = ?, updated_at = ?
+ WHERE id = ?""",
+ (title, meta_description, now, data_row["id"], now, article_id),
+ )
+ else:
+ article_id = await execute(
+ """INSERT INTO articles
+ (url_path, slug, title, meta_description, status, published_at,
+ template_data_id, created_at)
+ VALUES (?, ?, ?, ?, 'published', ?, ?, ?)""",
+ (url_path, slug, title, meta_description, now, data_row["id"], now),
+ )
+
+ await execute(
+ "UPDATE template_data SET article_id = ? WHERE id = ?",
+ (article_id, data_row["id"]),
+ )
+ return article_id
+
+
+# =============================================================================
+# Article routes
+# =============================================================================
+
+@bp.route("/")
+@admin_required
+async def cms_index():
+ articles = await list_articles()
+ return await render_template(
+ "admin/cms.html",
+ articles=articles,
+ published_count=sum(1 for a in articles if a["status"] == "published"),
+ draft_count=sum(1 for a in articles if a["status"] == "draft"),
+ )
+
+
+@bp.route("/new", methods=["GET", "POST"])
+@admin_required
+@csrf_protect
+async def cms_new():
+ if request.method == "POST":
+ form = await request.form
+ url_path = ("/" + form.get("url_path", "").strip("/")).rstrip("/") or "/"
+ slug = url_path.strip("/").replace("/", "-") or "untitled"
+ title = (form.get("title") or "").strip()
+ meta_description = (form.get("meta_description") or "").strip()
+ status = form.get("status", "draft")
+ now = datetime.utcnow().isoformat()
+ published_at = now if status == "published" else None
+
+ article_id = await execute(
+ """INSERT INTO articles (url_path, slug, title, meta_description, status, published_at, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
+ (url_path, slug, title, meta_description, status, published_at, now),
+ )
+ await flash(f"Article '{title}' created.", "success")
+ return redirect(url_for("cms.cms_edit", article_id=article_id))
+
+ return await render_template("admin/cms_editor.html", article=None)
+
+
+@bp.route("//edit", methods=["GET", "POST"])
+@admin_required
+@csrf_protect
+async def cms_edit(article_id: int):
+ article = await get_article(article_id)
+ if not article:
+ await flash("Article not found.", "error")
+ return redirect(url_for("cms.cms_index"))
+
+ if request.method == "POST":
+ form = await request.form
+ url_path = ("/" + form.get("url_path", "").strip("/")).rstrip("/") or "/"
+ slug = url_path.strip("/").replace("/", "-") or f"article-{article_id}"
+ title = (form.get("title") or "").strip()
+ meta_description = (form.get("meta_description") or "").strip()
+ og_image_url = (form.get("og_image_url") or "").strip()
+ status = form.get("status", "draft")
+ now = datetime.utcnow().isoformat()
+ published_at = article["published_at"] or (now if status == "published" else None)
+
+ await execute(
+ """UPDATE articles
+ SET url_path = ?, slug = ?, title = ?, meta_description = ?,
+ og_image_url = ?, status = ?, published_at = ?, updated_at = ?
+ WHERE id = ?""",
+ (url_path, slug, title, meta_description, og_image_url or None,
+ status, published_at, now, article_id),
+ )
+ await flash("Article saved.", "success")
+ return redirect(url_for("cms.cms_edit", article_id=article_id))
+
+ article = await get_article(article_id)
+ return await render_template("admin/cms_editor.html", article=article)
+
+
+@bp.route("//publish", methods=["POST"])
+@admin_required
+@csrf_protect
+async def cms_publish(article_id: int):
+ now = datetime.utcnow().isoformat()
+ await execute(
+ "UPDATE articles SET status = 'published', published_at = COALESCE(published_at, ?), updated_at = ? WHERE id = ?",
+ (now, now, article_id),
+ )
+ await flash("Article published.", "success")
+ return redirect(url_for("cms.cms_index"))
+
+
+@bp.route("//unpublish", methods=["POST"])
+@admin_required
+@csrf_protect
+async def cms_unpublish(article_id: int):
+ await execute(
+ "UPDATE articles SET status = 'draft', updated_at = ? WHERE id = ?",
+ (datetime.utcnow().isoformat(), article_id),
+ )
+ await flash("Article moved to draft.", "success")
+ return redirect(url_for("cms.cms_index"))
+
+
+@bp.route("//delete", methods=["POST"])
+@admin_required
+@csrf_protect
+async def cms_delete(article_id: int):
+ await execute("DELETE FROM articles WHERE id = ?", (article_id,))
+ await flash("Article deleted.", "success")
+ return redirect(url_for("cms.cms_index"))
+
+
+# =============================================================================
+# Article template routes
+# =============================================================================
+
+@bp.route("/templates")
+@admin_required
+async def template_list():
+ templates = await list_article_templates()
+ return await render_template("admin/cms_templates.html", templates=templates)
+
+
+@bp.route("/templates/new", methods=["GET", "POST"])
+@admin_required
+@csrf_protect
+async def template_new():
+ if request.method == "POST":
+ form = await request.form
+ name = (form.get("name") or "").strip()
+ slug = (form.get("slug") or name.lower().replace(" ", "-")).strip()
+ url_pattern = (form.get("url_pattern") or "").strip()
+ title_pattern = (form.get("title_pattern") or "").strip()
+ meta_description_pattern = (form.get("meta_description_pattern") or "").strip()
+ body_template = (form.get("body_template") or "").strip()
+ now = datetime.utcnow().isoformat()
+
+ template_id = await execute(
+ """INSERT INTO article_templates
+ (name, slug, url_pattern, title_pattern, meta_description_pattern, body_template, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
+ (name, slug, url_pattern, title_pattern, meta_description_pattern or None,
+ body_template, now),
+ )
+ await flash(f"Template '{name}' created.", "success")
+ return redirect(url_for("cms.template_data_view", template_id=template_id))
+
+ return await render_template("admin/cms_template_editor.html", template=None)
+
+
+@bp.route("/templates//edit", methods=["GET", "POST"])
+@admin_required
+@csrf_protect
+async def template_edit(template_id: int):
+ tmpl = await get_article_template(template_id)
+ if not tmpl:
+ await flash("Template not found.", "error")
+ return redirect(url_for("cms.template_list"))
+
+ if request.method == "POST":
+ form = await request.form
+ now = datetime.utcnow().isoformat()
+ await execute(
+ """UPDATE article_templates
+ SET name = ?, url_pattern = ?, title_pattern = ?,
+ meta_description_pattern = ?, body_template = ?, updated_at = ?
+ WHERE id = ?""",
+ (
+ (form.get("name") or tmpl["name"]).strip(),
+ (form.get("url_pattern") or "").strip(),
+ (form.get("title_pattern") or "").strip(),
+ (form.get("meta_description_pattern") or "").strip() or None,
+ (form.get("body_template") or "").strip(),
+ now,
+ template_id,
+ ),
+ )
+ await flash("Template saved.", "success")
+ return redirect(url_for("cms.template_data_view", template_id=template_id))
+
+ return await render_template("admin/cms_template_editor.html", template=tmpl)
+
+
+@bp.route("/templates/")
+@admin_required
+async def template_data_view(template_id: int):
+ tmpl = await get_article_template(template_id)
+ if not tmpl:
+ await flash("Template not found.", "error")
+ return redirect(url_for("cms.template_list"))
+ data_rows = await list_template_data(template_id)
+ return await render_template(
+ "admin/cms_template_data.html",
+ template=tmpl,
+ data_rows=data_rows,
+ )
+
+
+@bp.route("/templates//data/add", methods=["POST"])
+@admin_required
+@csrf_protect
+async def template_data_add(template_id: int):
+ form = await request.form
+ data_json = (form.get("data_json") or "{}").strip()
+ try:
+ json.loads(data_json)
+ except json.JSONDecodeError:
+ await flash("Invalid JSON.", "error")
+ return redirect(url_for("cms.template_data_view", template_id=template_id))
+
+ await execute(
+ "INSERT INTO template_data (template_id, data_json, created_at) VALUES (?, ?, ?)",
+ (template_id, data_json, datetime.utcnow().isoformat()),
+ )
+ await flash("Data row added.", "success")
+ return redirect(url_for("cms.template_data_view", template_id=template_id))
+
+
+@bp.route("/templates//generate", methods=["POST"])
+@admin_required
+@csrf_protect
+async def template_bulk_generate(template_id: int):
+ tmpl = await get_article_template(template_id)
+ if not tmpl:
+ await flash("Template not found.", "error")
+ return redirect(url_for("cms.template_list"))
+
+ pending = await fetch_all(
+ "SELECT * FROM template_data WHERE template_id = ? AND article_id IS NULL",
+ (template_id,),
+ )
+ generated = sum(1 for row in pending if await generate_article_from_data(row, tmpl))
+ await flash(f"Generated {generated} article(s) from {len(pending)} pending row(s).", "success")
+ return redirect(url_for("cms.template_data_view", template_id=template_id))
+
+
+@bp.route("/templates//regenerate", methods=["POST"])
+@admin_required
+@csrf_protect
+async def template_regenerate_all(template_id: int):
+ tmpl = await get_article_template(template_id)
+ if not tmpl:
+ await flash("Template not found.", "error")
+ return redirect(url_for("cms.template_list"))
+
+ all_rows = await list_template_data(template_id)
+ regenerated = sum(1 for row in all_rows if await generate_article_from_data(row, tmpl))
+ await flash(f"Regenerated {regenerated} article(s).", "success")
+ return redirect(url_for("cms.template_data_view", template_id=template_id))
diff --git a/web/src/beanflows/admin/routes.py b/web/src/beanflows/admin/routes.py
index ce559cf..31c6900 100644
--- a/web/src/beanflows/admin/routes.py
+++ b/web/src/beanflows/admin/routes.py
@@ -367,3 +367,62 @@ async def waitlist():
entries=entries,
total=len(entries),
)
+
+
+# =============================================================================
+# Feature Flags
+# =============================================================================
+
+@bp.route("/flags")
+@admin_required
+async def flags():
+ """Feature flags admin."""
+ flag_list = await fetch_all("SELECT * FROM feature_flags ORDER BY name")
+ return await render_template("admin/flags.html", flags=flag_list)
+
+
+@bp.route("/flags/create", methods=["POST"])
+@admin_required
+@csrf_protect
+async def flag_create():
+ """Create a feature flag."""
+ form = await request.form
+ name = (form.get("name") or "").strip().lower().replace(" ", "_")
+ description = (form.get("description") or "").strip()
+ if not name:
+ await flash("Flag name is required.", "error")
+ return redirect(url_for("admin.flags"))
+ await execute(
+ "INSERT OR IGNORE INTO feature_flags (name, enabled, description, updated_at) VALUES (?, 0, ?, ?)",
+ (name, description or None, datetime.utcnow().isoformat()),
+ )
+ await flash(f"Flag '{name}' created.", "success")
+ return redirect(url_for("admin.flags"))
+
+
+@bp.route("/flags/toggle", methods=["POST"])
+@admin_required
+@csrf_protect
+async def flag_toggle():
+ """Toggle a feature flag on/off."""
+ form = await request.form
+ name = (form.get("name") or "").strip()
+ await execute(
+ "UPDATE feature_flags SET enabled = NOT enabled, updated_at = ? WHERE name = ?",
+ (datetime.utcnow().isoformat(), name),
+ )
+ return redirect(url_for("admin.flags"))
+
+
+# =============================================================================
+# Email Log
+# =============================================================================
+
+@bp.route("/emails")
+@admin_required
+async def emails():
+ """Email log admin."""
+ log = await fetch_all(
+ "SELECT * FROM email_log ORDER BY created_at DESC LIMIT 500"
+ )
+ return await render_template("admin/emails.html", log=log)
diff --git a/web/src/beanflows/admin/templates/admin/base_admin.html b/web/src/beanflows/admin/templates/admin/base_admin.html
index 57248d5..8fc9798 100644
--- a/web/src/beanflows/admin/templates/admin/base_admin.html
+++ b/web/src/beanflows/admin/templates/admin/base_admin.html
@@ -319,6 +319,48 @@
Waitlist
+
+
+
+
diff --git a/web/src/beanflows/admin/templates/admin/cms.html b/web/src/beanflows/admin/templates/admin/cms.html
new file mode 100644
index 0000000..cb1e6e3
--- /dev/null
+++ b/web/src/beanflows/admin/templates/admin/cms.html
@@ -0,0 +1,92 @@
+{% set admin_page = "cms" %}
+{% extends "admin/base_admin.html" %}
+
+{% block title %}CMS Articles — {{ config.APP_NAME }} Admin{% endblock %}
+{% block topbar_title %}CMS Articles{% endblock %}
+
+{% block admin_content %}
+
+
+
Articles
+
+ {{ published_count }} published · {{ draft_count }} draft
+
+
+
+
+
+
+ {% if articles %}
+
+
+
+ Title
+ URL Path
+ Template
+ Status
+ Published
+
+
+
+
+ {% for article in articles %}
+
+
+
+ {{ article.title }}
+
+
+
+
+ {{ article.url_path }}
+
+
+
+ {% if article.template_name %}
+ {{ article.template_name }}
+ {% else %}
+ —
+ {% endif %}
+
+
+ {% if article.status == 'published' %}
+ published
+ {% else %}
+ draft
+ {% endif %}
+
+ {{ article.published_at[:10] if article.published_at else "—" }}
+
+
+
Edit
+ {% if article.status == 'published' %}
+
+ {% else %}
+
+ {% endif %}
+
+
+
+ {% endfor %}
+
+
+ {% else %}
+
+
No articles yet. Use pSEO Templates to bulk-generate coffee market pages.
+
+
+ {% endif %}
+
+{% endblock %}
diff --git a/web/src/beanflows/admin/templates/admin/cms_editor.html b/web/src/beanflows/admin/templates/admin/cms_editor.html
new file mode 100644
index 0000000..445368d
--- /dev/null
+++ b/web/src/beanflows/admin/templates/admin/cms_editor.html
@@ -0,0 +1,90 @@
+{% set admin_page = "cms" %}
+{% extends "admin/base_admin.html" %}
+
+{% block title %}{% if article %}Edit Article{% else %}New Article{% endif %} — Admin{% endblock %}
+{% block topbar_title %}{% if article %}Edit Article{% else %}New Article{% endif %}{% endblock %}
+
+{% block admin_content %}
+
+
+
+
+
+ {% if article %}
+
+
+ Body content:
+ {% if article.template_data_id %}
+ Programmatically generated from pSEO template data.
+ {% else %}
+ Hand-written article (no template linked).
+ {% endif %}
+
+
+ {% endif %}
+
+{% endblock %}
diff --git a/web/src/beanflows/admin/templates/admin/cms_template_data.html b/web/src/beanflows/admin/templates/admin/cms_template_data.html
new file mode 100644
index 0000000..2f7b5f7
--- /dev/null
+++ b/web/src/beanflows/admin/templates/admin/cms_template_data.html
@@ -0,0 +1,93 @@
+{% set admin_page = "cms" %}
+{% extends "admin/base_admin.html" %}
+
+{% block title %}{{ template.name }} Data — Admin{% endblock %}
+{% block topbar_title %}{{ template.name }}{% endblock %}
+
+{% block admin_content %}
+
+
+
+
+
{{ template.name }}
+
+ URL: {{ template.url_pattern }} ·
+ {{ data_rows | length }} rows ·
+ {{ data_rows | selectattr('article_id') | list | length }} generated
+
+
+
+
Edit Template
+
+
+ Generate Pending
+
+
+
+ Regenerate All
+
+
+
+
+
+
+
Add data row
+
+
+
+ JSON data
+
+
+ Add
+
+
+
+
+
+ {% if data_rows %}
+
+
+
+ #
+ Data
+ Article
+ Added
+
+
+
+ {% for row in data_rows %}
+
+ {{ row.id }}
+
+ {{ row.data_json }}
+
+
+ {% if row.article_id %}
+ generated #{{ row.article_id }}
+ {% else %}
+ pending
+ {% endif %}
+
+ {{ row.created_at[:10] }}
+
+ {% endfor %}
+
+
+ {% else %}
+
+
No data rows yet.
+
+ Run uv run python scripts/seed_cms_coffee.py to populate from DuckDB.
+
+
+ {% endif %}
+
+{% endblock %}
diff --git a/web/src/beanflows/admin/templates/admin/cms_template_editor.html b/web/src/beanflows/admin/templates/admin/cms_template_editor.html
new file mode 100644
index 0000000..927a457
--- /dev/null
+++ b/web/src/beanflows/admin/templates/admin/cms_template_editor.html
@@ -0,0 +1,82 @@
+{% set admin_page = "cms" %}
+{% extends "admin/base_admin.html" %}
+
+{% block title %}{% if template %}Edit Template{% else %}New Template{% endif %} — Admin{% endblock %}
+{% block topbar_title %}{% if template %}Edit Template{% else %}New Template{% endif %}{% endblock %}
+
+{% block admin_content %}
+
+
+
+
+
+
+
+
+
+ Save Template
+
+
+
+{% endblock %}
diff --git a/web/src/beanflows/admin/templates/admin/cms_templates.html b/web/src/beanflows/admin/templates/admin/cms_templates.html
new file mode 100644
index 0000000..63ef918
--- /dev/null
+++ b/web/src/beanflows/admin/templates/admin/cms_templates.html
@@ -0,0 +1,81 @@
+{% set admin_page = "cms" %}
+{% extends "admin/base_admin.html" %}
+
+{% block title %}Article Templates — {{ config.APP_NAME }} Admin{% endblock %}
+{% block topbar_title %}Article Templates{% endblock %}
+
+{% block admin_content %}
+
+
+
Article Templates
+
+ Jinja2 templates for programmatic article generation (pSEO).
+
+
+
+
+
+
+ {% if templates %}
+
+
+
+ Template
+ URL Pattern
+ Data Rows
+ Generated
+ Created
+
+
+
+
+ {% for tmpl in templates %}
+
+
+
+ {{ tmpl.name }}
+
+
+
+ {{ tmpl.url_pattern }}
+
+ {{ tmpl.data_count }}
+
+ {{ tmpl.generated_count }}
+ {% if tmpl.generated_count < tmpl.data_count %}
+
+ {{ tmpl.data_count - tmpl.generated_count }} pending
+
+ {% endif %}
+
+ {{ tmpl.created_at[:10] }}
+
+
+
Edit
+
+
+
+ Generate
+
+
+
+
+
+ {% endfor %}
+
+
+ {% else %}
+
+
No article templates yet.
+
+ Run uv run python scripts/seed_cms_coffee.py to seed coffee market templates.
+
+
Create template manually
+
+ {% endif %}
+
+{% endblock %}
diff --git a/web/src/beanflows/admin/templates/admin/emails.html b/web/src/beanflows/admin/templates/admin/emails.html
new file mode 100644
index 0000000..eeeb017
--- /dev/null
+++ b/web/src/beanflows/admin/templates/admin/emails.html
@@ -0,0 +1,86 @@
+{% set admin_page = "emails" %}
+{% extends "admin/base_admin.html" %}
+
+{% block title %}Email Log - {{ config.APP_NAME }} Admin{% endblock %}
+{% block topbar_title %}Email Log{% endblock %}
+
+{% block admin_content %}
+
+
+
Email Log
+
+ Audit trail of all outgoing transactional emails. Latest {{ log | length }} entries.
+
+
+
+ {% set sent_count = log | selectattr('status', 'equalto', 'sent') | list | length %}
+ {% set error_count = log | selectattr('status', 'equalto', 'error') | list | length %}
+ {% if sent_count > 0 %}
+ {{ sent_count }} sent
+ {% endif %}
+ {% if error_count > 0 %}
+ {{ error_count }} failed
+ {% endif %}
+
+
+
+
+ {% if log %}
+
+
+
+ Recipient
+ Subject
+ Template
+ Status
+ Provider ID
+ Sent
+
+
+
+ {% for entry in log %}
+
+ {{ entry.recipient }}
+
+ {{ entry.subject }}
+ {% if entry.error %}
+ {{ entry.error }}
+ {% endif %}
+
+
+ {% if entry.template %}
+ {{ entry.template }}
+ {% else %}
+ —
+ {% endif %}
+
+
+ {% if entry.status == 'sent' %}
+ sent
+ {% elif entry.status == 'dev_skip' %}
+ dev
+ {% elif entry.status == 'error' %}
+ error
+ {% else %}
+ {{ entry.status }}
+ {% endif %}
+
+
+ {% if entry.provider_id %}
+ {{ entry.provider_id[:16] }}…
+ {% else %}
+ —
+ {% endif %}
+
+ {{ entry.created_at[:16] }}
+
+ {% endfor %}
+
+
+ {% else %}
+
+
No emails logged yet.
+
+ {% endif %}
+
+{% endblock %}
diff --git a/web/src/beanflows/admin/templates/admin/flags.html b/web/src/beanflows/admin/templates/admin/flags.html
new file mode 100644
index 0000000..b6fac80
--- /dev/null
+++ b/web/src/beanflows/admin/templates/admin/flags.html
@@ -0,0 +1,88 @@
+{% set admin_page = "flags" %}
+{% extends "admin/base_admin.html" %}
+
+{% block title %}Feature Flags - {{ config.APP_NAME }} Admin{% endblock %}
+{% block topbar_title %}Feature Flags{% endblock %}
+
+{% block admin_content %}
+
+
+
Feature Flags
+
+ DB-backed toggles. Changes take effect immediately — no restart needed.
+
+
+
+ + New Flag
+
+
+
+
+
+
+ {% if flags %}
+
+
+
+ Flag
+ Description
+ State
+ Updated
+
+
+
+
+ {% for flag in flags %}
+
+
+
+ {{ flag.name }}
+
+
+ {{ flag.description or "—" }}
+
+ {% if flag.enabled %}
+ enabled
+ {% else %}
+ disabled
+ {% endif %}
+
+ {{ flag.updated_at[:16] }}
+
+
+
+
+
+ {{ "Disable" if flag.enabled else "Enable" }}
+
+
+
+
+ {% endfor %}
+
+
+ {% else %}
+
+
No feature flags yet. Create one above.
+
+ {% endif %}
+
+{% endblock %}
diff --git a/web/src/beanflows/app.py b/web/src/beanflows/app.py
index 546b2f9..6dab9ee 100644
--- a/web/src/beanflows/app.py
+++ b/web/src/beanflows/app.py
@@ -126,10 +126,12 @@ def create_app() -> Quart:
return result, status_code
# Register blueprints
+ from .admin.cms_routes import bp as cms_bp
from .admin.routes import bp as admin_bp
from .api.routes import bp as api_bp
from .auth.routes import bp as auth_bp
from .billing.routes import bp as billing_bp
+ from .content.routes import bp as content_bp
from .dashboard.routes import bp as dashboard_bp
from .public.routes import bp as public_bp
@@ -138,6 +140,8 @@ def create_app() -> Quart:
app.register_blueprint(dashboard_bp)
app.register_blueprint(billing_bp)
app.register_blueprint(api_bp, url_prefix="/api/v1")
+ app.register_blueprint(content_bp)
+ app.register_blueprint(cms_bp)
app.register_blueprint(admin_bp)
# Request ID tracking
diff --git a/web/src/beanflows/content/__init__.py b/web/src/beanflows/content/__init__.py
new file mode 100644
index 0000000..97234b9
--- /dev/null
+++ b/web/src/beanflows/content/__init__.py
@@ -0,0 +1 @@
+# Content package: public article serving for CMS-generated pages.
diff --git a/web/src/beanflows/content/routes.py b/web/src/beanflows/content/routes.py
new file mode 100644
index 0000000..bbd15e6
--- /dev/null
+++ b/web/src/beanflows/content/routes.py
@@ -0,0 +1,57 @@
+"""
+Content domain: public article serving for CMS-generated coffee market pages.
+"""
+import json
+from pathlib import Path
+
+from quart import Blueprint, abort, render_template
+
+from ..core import fetch_one
+
+bp = Blueprint(
+ "content",
+ __name__,
+ template_folder=str(Path(__file__).parent / "templates"),
+)
+
+
+async def _serve_article(url_path: str):
+ """Fetch article by url_path and render it with its body template."""
+ article = await fetch_one(
+ """SELECT a.*, td.data_json, at.body_template
+ FROM articles a
+ LEFT JOIN template_data td ON td.id = a.template_data_id
+ LEFT JOIN article_templates at ON at.id = td.template_id
+ WHERE a.url_path = ? AND a.status = 'published'""",
+ (url_path,),
+ )
+ if not article:
+ abort(404)
+
+ body_html = ""
+ if article.get("body_template") and article.get("data_json"):
+ try:
+ from jinja2 import Environment, BaseLoader
+ data = json.loads(article["data_json"])
+ env = Environment(loader=BaseLoader())
+ body_html = env.from_string(article["body_template"]).render(**data)
+ except Exception:
+ body_html = "Content temporarily unavailable.
"
+
+ return await render_template(
+ "content/article.html",
+ article=article,
+ body_html=body_html,
+ )
+
+
+@bp.route("/coffee/market/")
+async def market_year_article(year: int):
+ """Serve a CMS article at /coffee/market/."""
+ return await _serve_article(f"/coffee/market/{year}")
+
+
+@bp.route("/coffee/")
+async def country_article(slug: str):
+ """Serve a CMS article at /coffee/."""
+ return await _serve_article(f"/coffee/{slug}")
diff --git a/web/src/beanflows/content/templates/content/article.html b/web/src/beanflows/content/templates/content/article.html
new file mode 100644
index 0000000..1500e30
--- /dev/null
+++ b/web/src/beanflows/content/templates/content/article.html
@@ -0,0 +1,75 @@
+{% extends "base.html" %}
+
+{% block title %}{{ article.title }}{% endblock %}
+
+{% block head %}
+
+ {% if article.og_image_url %}
+
+ {% endif %}
+
+
+
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+ Home
+ ›
+ Coffee
+ ›
+ {{ article.title }}
+
+
+
+
+
+ {{ article.title }}
+
+ {% if article.meta_description %}
+
+ {{ article.meta_description }}
+
+ {% endif %}
+
+ Published {{ article.published_at[:10] if article.published_at else "" }}
+ · Data source: USDA PSD Online
+
+
+
+
+
+ {{ body_html | safe }}
+
+
+
+
+
Track live coffee market data
+
+ BeanFlows provides daily-updated supply & demand analytics, CFTC positioning data,
+ and ICE warehouse stocks for coffee traders.
+
+
Get Started Free →
+
+
+
+{% endblock %}
diff --git a/web/src/beanflows/core.py b/web/src/beanflows/core.py
index cdf765b..e9b5c0d 100644
--- a/web/src/beanflows/core.py
+++ b/web/src/beanflows/core.py
@@ -175,26 +175,41 @@ EMAIL_ADDRESSES = {
async def send_email(
- to: str, subject: str, html: str, text: str = None, from_addr: str = None
+ to: str, subject: str, html: str, text: str = None,
+ from_addr: str = None, template: str = None,
) -> bool:
- """Send email via Resend SDK."""
+ """Send email via Resend SDK and log to email_log table."""
if not config.RESEND_API_KEY:
print(f"[EMAIL] Would send to {to}: {subject}")
+ await execute(
+ "INSERT INTO email_log (recipient, subject, template, status, created_at) VALUES (?, ?, ?, 'dev_skip', ?)",
+ (to, subject, template, datetime.utcnow().isoformat()),
+ )
return True
resend.api_key = config.RESEND_API_KEY
+ provider_id = None
+ error_msg = None
try:
- resend.Emails.send({
+ result = resend.Emails.send({
"from": from_addr or config.EMAIL_FROM,
"to": to,
"subject": subject,
"html": html,
"text": text or html,
})
- return True
+ provider_id = result.get("id") if isinstance(result, dict) else None
except Exception as e:
+ error_msg = str(e)
print(f"[EMAIL] Error sending to {to}: {e}")
- return False
+
+ await execute(
+ """INSERT INTO email_log (recipient, subject, template, status, provider_id, error, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
+ (to, subject, template, "error" if error_msg else "sent",
+ provider_id, error_msg, datetime.utcnow().isoformat()),
+ )
+ return error_msg is None
# =============================================================================
# CSRF Protection
@@ -434,4 +449,47 @@ def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
response.set_cookie(cookie_key, assigned, max_age=30 * 24 * 60 * 60)
return response
return wrapper
+ return decorator
+
+
+# =============================================================================
+# Feature Flags (DB-backed, admin-toggleable)
+# =============================================================================
+
+async def is_flag_enabled(name: str, default: bool = False) -> bool:
+ """Check if a feature flag is enabled. Falls back to default if not found."""
+ row = await fetch_one("SELECT enabled FROM feature_flags WHERE name = ?", (name,))
+ if row is None:
+ return default
+ return bool(row["enabled"])
+
+
+async def set_flag(name: str, enabled: bool, description: str = None) -> None:
+ """Create or update a feature flag."""
+ await execute(
+ """INSERT INTO feature_flags (name, enabled, description, updated_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(name) DO UPDATE SET enabled = excluded.enabled,
+ updated_at = excluded.updated_at""",
+ (name, int(enabled), description, datetime.utcnow().isoformat()),
+ )
+
+
+async def get_all_flags() -> list[dict]:
+ """Return all feature flags ordered by name."""
+ return await fetch_all("SELECT * FROM feature_flags ORDER BY name")
+
+
+def feature_gate(flag_name: str, fallback_template: str, **extra_context):
+ """Gate a route behind a feature flag; renders fallback on GET, 403 on POST."""
+ def decorator(f):
+ @wraps(f)
+ async def decorated(*args, **kwargs):
+ if not await is_flag_enabled(flag_name):
+ if request.method == "GET":
+ ctx = {k: v() if callable(v) else v for k, v in extra_context.items()}
+ return await render_template(fallback_template, **ctx)
+ return {"error": "Feature not available"}, 403
+ return await f(*args, **kwargs)
+ return decorated
return decorator
\ No newline at end of file
diff --git a/web/src/beanflows/migrations/0002_feature_flags_cms.py b/web/src/beanflows/migrations/0002_feature_flags_cms.py
new file mode 100644
index 0000000..c78ea0d
--- /dev/null
+++ b/web/src/beanflows/migrations/0002_feature_flags_cms.py
@@ -0,0 +1 @@
+# This file was created in the wrong location. See versions/0002_feature_flags_cms.py
diff --git a/web/src/beanflows/migrations/versions/0002_feature_flags_cms.py b/web/src/beanflows/migrations/versions/0002_feature_flags_cms.py
new file mode 100644
index 0000000..3cd41ad
--- /dev/null
+++ b/web/src/beanflows/migrations/versions/0002_feature_flags_cms.py
@@ -0,0 +1,116 @@
+"""
+Migration 0002 — feature_flags, email_log, and CMS tables.
+
+Adds:
+ - feature_flags: DB-backed feature toggles (admin UI, no restart)
+ - email_log: append-only audit trail of all outgoing emails
+ - article_templates: Jinja2 body templates for pSEO generation
+ - template_data: JSON data rows for bulk article generation
+ - articles: CMS article records with FTS5 search
+"""
+
+SQL_DDL = """\
+CREATE TABLE IF NOT EXISTS feature_flags (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT UNIQUE NOT NULL,
+ enabled INTEGER NOT NULL DEFAULT 0,
+ description TEXT,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_feature_flags_name ON feature_flags(name);
+
+CREATE TABLE IF NOT EXISTS email_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ recipient TEXT NOT NULL,
+ subject TEXT NOT NULL,
+ template TEXT,
+ status TEXT NOT NULL DEFAULT 'sent',
+ provider_id TEXT,
+ error TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_email_log_recipient ON email_log(recipient);
+CREATE INDEX IF NOT EXISTS idx_email_log_created ON email_log(created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_email_log_status ON email_log(status);
+
+CREATE TABLE IF NOT EXISTS article_templates (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ slug TEXT UNIQUE NOT NULL,
+ url_pattern TEXT NOT NULL,
+ title_pattern TEXT NOT NULL,
+ meta_description_pattern TEXT,
+ body_template TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_article_templates_slug ON article_templates(slug);
+
+CREATE TABLE IF NOT EXISTS articles (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ url_path TEXT UNIQUE NOT NULL,
+ slug TEXT UNIQUE NOT NULL,
+ title TEXT NOT NULL,
+ meta_description TEXT,
+ og_image_url TEXT,
+ status TEXT NOT NULL DEFAULT 'draft',
+ published_at TEXT,
+ template_data_id INTEGER,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_articles_url_path ON articles(url_path);
+CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug);
+CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status, published_at);
+
+CREATE TABLE IF NOT EXISTS template_data (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ template_id INTEGER NOT NULL REFERENCES article_templates(id),
+ data_json TEXT NOT NULL,
+ article_id INTEGER REFERENCES articles(id),
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_template_data_template ON template_data(template_id);
+
+CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
+ title,
+ meta_description,
+ content='articles',
+ content_rowid='id'
+)"""
+
+# Triggers are kept separate because split(";") would break BEGIN...END blocks.
+SQL_TRIGGERS = [
+ """\
+CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
+ INSERT INTO articles_fts(rowid, title, meta_description)
+ VALUES (new.id, new.title, new.meta_description);
+END""",
+ """\
+CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
+ INSERT INTO articles_fts(articles_fts, rowid, title, meta_description)
+ VALUES ('delete', old.id, old.title, old.meta_description);
+END""",
+ """\
+CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
+ INSERT INTO articles_fts(articles_fts, rowid, title, meta_description)
+ VALUES ('delete', old.id, old.title, old.meta_description);
+ INSERT INTO articles_fts(rowid, title, meta_description)
+ VALUES (new.id, new.title, new.meta_description);
+END""",
+]
+
+
+def up(conn):
+ for statement in SQL_DDL.split(";"):
+ stmt = statement.strip()
+ if stmt:
+ conn.execute(stmt)
+ for trigger in SQL_TRIGGERS:
+ conn.execute(trigger)
diff --git a/web/src/beanflows/public/routes.py b/web/src/beanflows/public/routes.py
index 7afd75e..dbc8344 100644
--- a/web/src/beanflows/public/routes.py
+++ b/web/src/beanflows/public/routes.py
@@ -6,7 +6,7 @@ from datetime import datetime
from quart import Blueprint, render_template, request, g, make_response
-from ..core import config, execute, check_rate_limit, csrf_protect
+from ..core import config, execute, fetch_all, check_rate_limit, csrf_protect
# Blueprint with its own template folder
bp = Blueprint(
@@ -117,6 +117,7 @@ async def sitemap_xml():
f" \n"
)
+ today = datetime.utcnow().date().isoformat()
xml = '\n'
xml += '\n'
xml += url_entry(f"{base}/", priority="1.0", changefreq="daily")
@@ -126,7 +127,27 @@ async def sitemap_xml():
xml += url_entry(f"{base}/terms", priority="0.3", changefreq="yearly")
xml += url_entry(f"{base}/privacy", priority="0.3", changefreq="yearly")
xml += url_entry(f"{base}/imprint", priority="0.2", changefreq="yearly")
- # Add dynamic BeanFlows entries here (e.g. public commodity pages)
+
+ # CMS articles
+ try:
+ articles = await fetch_all(
+ "SELECT url_path, updated_at, published_at FROM articles WHERE status = 'published' ORDER BY published_at DESC LIMIT 500"
+ )
+ except Exception:
+ articles = []
+
+ for article in articles:
+ lastmod = (article.get("updated_at") or article.get("published_at") or today)[:10]
+ loc = f"{base}{article['url_path']}"
+ xml += (
+ f" \n"
+ f" {loc} \n"
+ f" {lastmod} \n"
+ f" monthly \n"
+ f" 0.6 \n"
+ f" \n"
+ )
+
xml += " "
response = await make_response(xml)