summaryrefslogtreecommitdiff
path: root/Biz
diff options
context:
space:
mode:
authorBen Sima <ben@bensima.com>2025-12-17 13:29:40 -0500
committerBen Sima <ben@bensima.com>2025-12-17 13:29:40 -0500
commitab01b34bf563990e0f491ada646472aaade97610 (patch)
tree5e46a1a157bb846b0c3a090a83153c788da2b977 /Biz
parente112d3ce07fa24f31a281e521a554cc881a76c7b (diff)
parent337648981cc5a55935116141341521f4fce83214 (diff)
Merge Ava deployment changes
Diffstat (limited to 'Biz')
-rwxr-xr-xBiz/EmailAgent.py7
-rw-r--r--Biz/Packages.nix5
-rw-r--r--Biz/PodcastItLater/Admin.py94
-rw-r--r--Biz/PodcastItLater/Admin/Core.py95
-rw-r--r--Biz/PodcastItLater/Admin/Handlers.py28
-rw-r--r--Biz/PodcastItLater/Admin/Views.py107
-rw-r--r--Biz/PodcastItLater/Admin/__init__.py1
-rw-r--r--Biz/PodcastItLater/Core.py156
-rw-r--r--Biz/PodcastItLater/INFRASTRUCTURE.md46
-rw-r--r--Biz/PodcastItLater/UI.py248
-rw-r--r--Biz/PodcastItLater/Web.nix4
-rw-r--r--Biz/PodcastItLater/Web.py220
-rw-r--r--Biz/PodcastItLater/Worker/Core.py (renamed from Biz/PodcastItLater/Worker.py)5
-rw-r--r--Biz/PodcastItLater/Worker/Jobs.py2
-rw-r--r--Biz/PodcastItLater/Worker/Processor.py18
15 files changed, 868 insertions, 168 deletions
diff --git a/Biz/EmailAgent.py b/Biz/EmailAgent.py
index 6ac4c95..ca42de3 100755
--- a/Biz/EmailAgent.py
+++ b/Biz/EmailAgent.py
@@ -31,7 +31,7 @@ def send_email(
Send an email using the provided parameters.
Args:
- to_addr: Recipient email addresses
+ to_addrs: Recipient email addresses
from_addr: Sender email address
smtp_server: SMTP server hostname
password: Password for authentication
@@ -56,8 +56,9 @@ def send_email(
with body_html.open(encoding="utf-*") as html:
msg.add_alternative(html.read(), subtype="html")
with smtplib.SMTP(smtp_server, port) as server:
- server.starttls()
- server.login(from_addr, password)
+ if password:
+ server.starttls()
+ server.login(from_addr, password)
return server.send_message(
msg,
from_addr=from_addr,
diff --git a/Biz/Packages.nix b/Biz/Packages.nix
index 6b17fe5..492671f 100644
--- a/Biz/Packages.nix
+++ b/Biz/Packages.nix
@@ -10,6 +10,9 @@
{bild ? import ../Omni/Bild.nix {}}: {
storybook = bild.run ../Biz/Storybook.py;
podcastitlater-web = bild.run ../Biz/PodcastItLater/Web.py;
- podcastitlater-worker = bild.run ../Biz/PodcastItLater/Worker.py;
+ podcastitlater-worker = bild.run ../Biz/PodcastItLater/Worker/Core.py;
dragons-analysis = bild.run ../Biz/Dragons/Analysis.hs;
+ # Mini-PaaS deployer
+ biz-deployer = bild.run ../Omni/Deploy/Deployer.hs;
+ deploy-manifest = bild.run ../Omni/Deploy/Manifest.hs;
}
diff --git a/Biz/PodcastItLater/Admin.py b/Biz/PodcastItLater/Admin.py
deleted file mode 100644
index 3fc6f61..0000000
--- a/Biz/PodcastItLater/Admin.py
+++ /dev/null
@@ -1,94 +0,0 @@
-"""
-PodcastItLater Admin Interface.
-
-Admin pages and functionality for managing users and queue items.
-"""
-
-# : out podcastitlater-admin
-# : dep ludic
-# : dep httpx
-# : dep starlette
-# : dep pytest
-# : dep pytest-asyncio
-# : dep pytest-mock
-
-# i need to import these unused because bild cannot get local transitive python
-# dependencies yet
-import Omni.App as App # noqa: F401
-import Omni.Log as Log # noqa: F401
-import Omni.Test as Test # noqa: F401
-import sys
-from Biz.PodcastItLater.Admin.Handlers import admin_metrics
-from Biz.PodcastItLater.Admin.Handlers import admin_queue_status
-from Biz.PodcastItLater.Admin.Handlers import admin_users
-from Biz.PodcastItLater.Admin.Handlers import delete_queue_item
-from Biz.PodcastItLater.Admin.Handlers import retry_queue_item
-from Biz.PodcastItLater.Admin.Handlers import toggle_episode_public
-from Biz.PodcastItLater.Admin.Handlers import update_user_status
-
-# Import all views and handlers from the new modules
-from Biz.PodcastItLater.Admin.Views import ActionButtons
-from Biz.PodcastItLater.Admin.Views import ActionButtonsAttrs
-from Biz.PodcastItLater.Admin.Views import AdminUsers
-from Biz.PodcastItLater.Admin.Views import AdminUsersAttrs
-from Biz.PodcastItLater.Admin.Views import AdminView
-from Biz.PodcastItLater.Admin.Views import AdminViewAttrs
-from Biz.PodcastItLater.Admin.Views import EpisodeTableRow
-from Biz.PodcastItLater.Admin.Views import EpisodeTableRowAttrs
-from Biz.PodcastItLater.Admin.Views import MetricCard
-from Biz.PodcastItLater.Admin.Views import MetricCardAttrs
-from Biz.PodcastItLater.Admin.Views import MetricsAttrs
-from Biz.PodcastItLater.Admin.Views import MetricsDashboard
-from Biz.PodcastItLater.Admin.Views import QueueTableRow
-from Biz.PodcastItLater.Admin.Views import QueueTableRowAttrs
-from Biz.PodcastItLater.Admin.Views import StatusBadge
-from Biz.PodcastItLater.Admin.Views import StatusBadgeAttrs
-from Biz.PodcastItLater.Admin.Views import TopEpisodesTable
-from Biz.PodcastItLater.Admin.Views import TopEpisodesTableAttrs
-from Biz.PodcastItLater.Admin.Views import TruncatedText
-from Biz.PodcastItLater.Admin.Views import TruncatedTextAttrs
-from Biz.PodcastItLater.Admin.Views import UserTableRow
-from Biz.PodcastItLater.Admin.Views import UserTableRowAttrs
-from Biz.PodcastItLater.Admin.Views import create_table_header
-
-# Export all symbols for backward compatibility
-__all__ = [
- # Views
- "ActionButtons",
- "ActionButtonsAttrs",
- "AdminUsers",
- "AdminUsersAttrs",
- "AdminView",
- "AdminViewAttrs",
- "EpisodeTableRow",
- "EpisodeTableRowAttrs",
- "MetricCard",
- "MetricCardAttrs",
- "MetricsAttrs",
- "MetricsDashboard",
- "QueueTableRow",
- "QueueTableRowAttrs",
- "StatusBadge",
- "StatusBadgeAttrs",
- "TopEpisodesTable",
- "TopEpisodesTableAttrs",
- "TruncatedText",
- "TruncatedTextAttrs",
- "UserTableRow",
- "UserTableRowAttrs",
- # Handlers
- "admin_metrics",
- "admin_queue_status",
- "admin_users",
- "create_table_header",
- "delete_queue_item",
- "retry_queue_item",
- "toggle_episode_public",
- "update_user_status",
-]
-
-
-def main() -> None:
- """Admin tests are currently in Web."""
- if "test" in sys.argv:
- sys.exit(0)
diff --git a/Biz/PodcastItLater/Admin/Core.py b/Biz/PodcastItLater/Admin/Core.py
new file mode 100644
index 0000000..10ea7f6
--- /dev/null
+++ b/Biz/PodcastItLater/Admin/Core.py
@@ -0,0 +1,95 @@
+"""
+PodcastItLater Admin Interface.
+
+Admin pages and functionality for managing users and queue items.
+"""
+
+# : out podcastitlater-admin
+# : dep ludic
+# : dep httpx
+# : dep starlette
+# : dep pytest
+# : dep pytest-asyncio
+# : dep pytest-mock
+
+import Biz.PodcastItLater.Admin.Handlers as Handlers
+import Biz.PodcastItLater.Admin.Views as Views
+import sys
+
+# Re-export all symbols for backward compatibility
+ActionButtons = Views.ActionButtons
+ActionButtonsAttrs = Views.ActionButtonsAttrs
+AdminUsers = Views.AdminUsers
+AdminUsersAttrs = Views.AdminUsersAttrs
+AdminView = Views.AdminView
+AdminViewAttrs = Views.AdminViewAttrs
+EpisodeTableRow = Views.EpisodeTableRow
+EpisodeTableRowAttrs = Views.EpisodeTableRowAttrs
+MetricCard = Views.MetricCard
+MetricCardAttrs = Views.MetricCardAttrs
+MetricsAttrs = Views.MetricsAttrs
+MetricsDashboard = Views.MetricsDashboard
+QueueTableRow = Views.QueueTableRow
+QueueTableRowAttrs = Views.QueueTableRowAttrs
+StatusBadge = Views.StatusBadge
+StatusBadgeAttrs = Views.StatusBadgeAttrs
+TopEpisodesTable = Views.TopEpisodesTable
+TopEpisodesTableAttrs = Views.TopEpisodesTableAttrs
+TruncatedText = Views.TruncatedText
+TruncatedTextAttrs = Views.TruncatedTextAttrs
+UserTableRow = Views.UserTableRow
+UserTableRowAttrs = Views.UserTableRowAttrs
+create_table_header = Views.create_table_header
+AdminFeedback = Views.AdminFeedback
+AdminFeedbackAttrs = Views.AdminFeedbackAttrs
+
+admin_feedback = Handlers.admin_feedback
+admin_metrics = Handlers.admin_metrics
+admin_queue_status = Handlers.admin_queue_status
+admin_users = Handlers.admin_users
+delete_queue_item = Handlers.delete_queue_item
+retry_queue_item = Handlers.retry_queue_item
+toggle_episode_public = Handlers.toggle_episode_public
+update_user_status = Handlers.update_user_status
+
+__all__ = [
+ "ActionButtons",
+ "ActionButtonsAttrs",
+ "AdminFeedback",
+ "AdminFeedbackAttrs",
+ "AdminUsers",
+ "AdminUsersAttrs",
+ "AdminView",
+ "AdminViewAttrs",
+ "EpisodeTableRow",
+ "EpisodeTableRowAttrs",
+ "MetricCard",
+ "MetricCardAttrs",
+ "MetricsAttrs",
+ "MetricsDashboard",
+ "QueueTableRow",
+ "QueueTableRowAttrs",
+ "StatusBadge",
+ "StatusBadgeAttrs",
+ "TopEpisodesTable",
+ "TopEpisodesTableAttrs",
+ "TruncatedText",
+ "TruncatedTextAttrs",
+ "UserTableRow",
+ "UserTableRowAttrs",
+ "admin_feedback",
+ "admin_metrics",
+ "admin_queue_status",
+ "admin_users",
+ "create_table_header",
+ "delete_queue_item",
+ "retry_queue_item",
+ "toggle_episode_public",
+ "update_user_status",
+]
+
+
+def main() -> None:
+ """Admin tests are currently in Web."""
+ if "test" in sys.argv:
+ sys.exit(0)
diff --git a/Biz/PodcastItLater/Admin/Handlers.py b/Biz/PodcastItLater/Admin/Handlers.py
index b98c551..9e06fe6 100644
--- a/Biz/PodcastItLater/Admin/Handlers.py
+++ b/Biz/PodcastItLater/Admin/Handlers.py
@@ -296,3 +296,31 @@ def admin_metrics(request: Request) -> Views.MetricsDashboard | Response:
metrics = Core.Database.get_metrics_summary()
return Views.MetricsDashboard(metrics=metrics, user=user)
+
+
+def admin_feedback(request: Request) -> Views.AdminFeedback | Response:
+ """Admin feedback view."""
+ # Check if user is logged in and is admin
+ user_id = request.session.get("user_id")
+ if not user_id:
+ return Response(
+ "",
+ status_code=302,
+ headers={"Location": "/"},
+ )
+
+ user = Core.Database.get_user_by_id(
+ user_id,
+ )
+ if not user or not Core.is_admin(user):
+ return Response(
+ "",
+ status_code=302,
+ headers={"Location": "/?error=forbidden"},
+ )
+
+ # Get feedback data
+ feedback = Core.Database.get_feedback(limit=100)
+ count = Core.Database.get_feedback_count()
+
+ return Views.AdminFeedback(feedback=feedback, count=count, user=user)
diff --git a/Biz/PodcastItLater/Admin/Views.py b/Biz/PodcastItLater/Admin/Views.py
index 7cc71a5..057c5e0 100644
--- a/Biz/PodcastItLater/Admin/Views.py
+++ b/Biz/PodcastItLater/Admin/Views.py
@@ -263,16 +263,16 @@ class MetricsDashboard(Component[AnyChildren, MetricsAttrs]):
"Most Played",
classes=["card-title", "mb-0"],
),
- classes=["card-header", "bg-white"],
+ classes=["card-header"],
),
TopEpisodesTable(
episodes=metrics["most_played"],
metric_name="Plays",
count_key="play_count",
),
- classes=["card", "shadow-sm"],
+ classes=["card", "shadow-sm", "mb-3"],
),
- classes=["col-lg-4"],
+ classes=["col-12"],
),
html.div(
html.div(
@@ -288,16 +288,16 @@ class MetricsDashboard(Component[AnyChildren, MetricsAttrs]):
"Most Downloaded",
classes=["card-title", "mb-0"],
),
- classes=["card-header", "bg-white"],
+ classes=["card-header"],
),
TopEpisodesTable(
episodes=metrics["most_downloaded"],
metric_name="Downloads",
count_key="download_count",
),
- classes=["card", "shadow-sm"],
+ classes=["card", "shadow-sm", "mb-3"],
),
- classes=["col-lg-4"],
+ classes=["col-12"],
),
html.div(
html.div(
@@ -313,16 +313,16 @@ class MetricsDashboard(Component[AnyChildren, MetricsAttrs]):
"Most Added to Feeds",
classes=["card-title", "mb-0"],
),
- classes=["card-header", "bg-white"],
+ classes=["card-header"],
),
TopEpisodesTable(
episodes=metrics["most_added"],
metric_name="Adds",
count_key="add_count",
),
- classes=["card", "shadow-sm"],
+ classes=["card", "shadow-sm", "mb-3"],
),
- classes=["col-lg-4"],
+ classes=["col-12"],
),
classes=["row", "g-3"],
),
@@ -742,3 +742,92 @@ class AdminView(Component[AnyChildren, AdminViewAttrs]):
),
),
)
+
+
+class AdminFeedbackAttrs(Attrs):
+ """Attributes for AdminFeedback component."""
+
+ feedback: list[dict[str, typing.Any]]
+ count: int
+ user: dict[str, typing.Any]
+
+
+class AdminFeedback(Component[AnyChildren, AdminFeedbackAttrs]):
+ """Admin feedback listing page."""
+
+ @override
+ def render(self) -> UI.PageLayout:
+ feedback = self.attrs["feedback"]
+ count = self.attrs["count"]
+ user = self.attrs["user"]
+
+ rows = [
+ html.tr(
+ html.td(html.small(fb["id"][:8], classes=["text-muted"])),
+ html.td(fb.get("email") or "-"),
+ html.td(
+ html.span(
+ fb.get("source") or "-",
+ classes=["badge", "bg-secondary"],
+ ),
+ ),
+ html.td(str(fb.get("rating") or "-")),
+ html.td(
+ TruncatedText(
+ text=fb.get("use_case") or "-",
+ max_length=50,
+ ),
+ ),
+ html.td(
+ TruncatedText(
+ text=fb.get("feedback_text") or "-",
+ max_length=50,
+ ),
+ ),
+ html.td(
+ html.small(
+ fb["created_at"],
+ classes=["text-muted"],
+ ),
+ ),
+ )
+ for fb in feedback
+ ]
+
+ return UI.PageLayout(
+ html.div(
+ html.h2(
+ html.i(classes=["bi", "bi-chat-heart-fill", "me-2"]),
+ f"Feedback ({count})",
+ classes=["mb-4"],
+ ),
+ html.div(
+ html.table(
+ create_table_header([
+ "ID",
+ "Email",
+ "Source",
+ "Rating",
+ "Use Case",
+ "Feedback",
+ "Created",
+ ]),
+ html.tbody(*rows),
+ classes=["table", "table-hover", "table-sm"],
+ ),
+ classes=["table-responsive"],
+ )
+ if rows
+ else html.div(
+ html.p(
+ "No feedback yet.",
+ classes=["text-muted", "text-center", "py-5"],
+ ),
+ ),
+ ),
+ user=user,
+ current_page="admin",
+ page_title="Admin Feedback - PodcastItLater",
+ error=None,
+ meta_tags=[],
+ )
diff --git a/Biz/PodcastItLater/Admin/__init__.py b/Biz/PodcastItLater/Admin/__init__.py
deleted file mode 100644
index 04e3e32..0000000
--- a/Biz/PodcastItLater/Admin/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""PodcastItLater Admin package."""
diff --git a/Biz/PodcastItLater/Core.py b/Biz/PodcastItLater/Core.py
index 3a88f22..05ed153 100644
--- a/Biz/PodcastItLater/Core.py
+++ b/Biz/PodcastItLater/Core.py
@@ -202,6 +202,9 @@ class Database: # noqa: PLR0904
# Run migration to add public feed features
Database.migrate_add_public_feed()
+ # Run migration to add feedback table
+ Database.migrate_add_feedback_table()
+
@staticmethod
def add_to_queue(
url: str,
@@ -774,6 +777,42 @@ class Database: # noqa: PLR0904
logger.info("Database migrated for public feed feature")
@staticmethod
+ def migrate_add_feedback_table() -> None:
+ """Add feedback table for collecting user research data."""
+ with Database.get_connection() as conn:
+ cursor = conn.cursor()
+
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS feedback (
+ id TEXT PRIMARY KEY,
+ email TEXT,
+ source TEXT,
+ campaign_id TEXT,
+ rating INTEGER,
+ feedback_text TEXT,
+ use_case TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+
+ # Create indexes for querying feedback
+ cursor.execute(
+ "CREATE INDEX IF NOT EXISTS idx_feedback_source "
+ "ON feedback(source)",
+ )
+ cursor.execute(
+ "CREATE INDEX IF NOT EXISTS idx_feedback_campaign "
+ "ON feedback(campaign_id)",
+ )
+ cursor.execute(
+ "CREATE INDEX IF NOT EXISTS idx_feedback_created "
+ "ON feedback(created_at)",
+ )
+
+ conn.commit()
+ logger.info("Created feedback table")
+
+ @staticmethod
def migrate_add_default_titles() -> None:
"""Add default titles to queue items that have None titles."""
with Database.get_connection() as conn:
@@ -1073,6 +1112,18 @@ class Database: # noqa: PLR0904
logger.info("Unmarked episode %s as public", episode_id)
@staticmethod
+ def toggle_episode_public(episode_id: int) -> None:
+ """Toggle an episode's public status."""
+ with Database.get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute(
+ "UPDATE episodes SET is_public = NOT is_public WHERE id = ?",
+ (episode_id,),
+ )
+ conn.commit()
+ logger.info("Toggled public status for episode %s", episode_id)
+
+ @staticmethod
def get_public_episodes(limit: int = 50) -> list[dict[str, Any]]:
"""Get public episodes for public feed."""
with Database.get_connection() as conn:
@@ -1189,6 +1240,111 @@ class Database: # noqa: PLR0904
return dict(row) if row is not None else None
@staticmethod
+ def create_feedback( # noqa: PLR0913, PLR0917
+ feedback_id: str,
+ email: str | None,
+ source: str | None,
+ campaign_id: str | None,
+ rating: int | None,
+ feedback_text: str | None,
+ use_case: str | None,
+ ) -> str:
+ """Create a new feedback entry.
+
+ Args:
+ feedback_id: Unique ID for the feedback
+ email: Optional email address
+ source: How they heard about PIL (outreach, organic, trial)
+ campaign_id: Optional link to outreach draft ID
+ rating: Optional 1-5 rating
+ feedback_text: Optional general feedback
+ use_case: What they want to use PIL for
+
+ Returns:
+ The feedback ID
+ """
+ with Database.get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute(
+ """
+ INSERT INTO feedback
+ (id, email, source, campaign_id, rating,
+ feedback_text, use_case)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ feedback_id,
+ email,
+ source,
+ campaign_id,
+ rating,
+ feedback_text,
+ use_case,
+ ),
+ )
+ conn.commit()
+ logger.info("Created feedback %s", feedback_id)
+ return feedback_id
+
+ @staticmethod
+ def get_feedback(
+ limit: int = 50,
+ since: str | None = None,
+ ) -> list[dict[str, Any]]:
+ """Get feedback entries.
+
+ Args:
+ limit: Maximum number of entries to return
+ since: Optional ISO date to filter by (get entries after this date)
+
+ Returns:
+ List of feedback entries
+ """
+ with Database.get_connection() as conn:
+ cursor = conn.cursor()
+ if since:
+ cursor.execute(
+ """
+ SELECT * FROM feedback
+ WHERE created_at >= ?
+ ORDER BY created_at DESC
+ LIMIT ?
+ """,
+ (since, limit),
+ )
+ else:
+ cursor.execute(
+ """
+ SELECT * FROM feedback
+ ORDER BY created_at DESC
+ LIMIT ?
+ """,
+ (limit,),
+ )
+ rows = cursor.fetchall()
+ return [dict(row) for row in rows]
+
+ @staticmethod
+ def get_feedback_by_id(feedback_id: str) -> dict[str, Any] | None:
+ """Get a single feedback entry by ID."""
+ with Database.get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute(
+ "SELECT * FROM feedback WHERE id = ?",
+ (feedback_id,),
+ )
+ row = cursor.fetchone()
+ return dict(row) if row is not None else None
+
+ @staticmethod
+ def get_feedback_count() -> int:
+ """Get total count of feedback entries."""
+ with Database.get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT COUNT(*) as count FROM feedback")
+ return int(cursor.fetchone()["count"])
+
+ @staticmethod
def get_metrics_summary() -> dict[str, Any]:
"""Get aggregate metrics summary for admin dashboard.
diff --git a/Biz/PodcastItLater/INFRASTRUCTURE.md b/Biz/PodcastItLater/INFRASTRUCTURE.md
index 1c61618..0d6392b 100644
--- a/Biz/PodcastItLater/INFRASTRUCTURE.md
+++ b/Biz/PodcastItLater/INFRASTRUCTURE.md
@@ -1,38 +1,24 @@
# Infrastructure Setup for PodcastItLater
-## Mailgun Setup
+## Email Delivery via Mailgun
-Since PodcastItLater requires sending transactional emails (magic links), we use Mailgun.
+PodcastItLater sends transactional emails (magic links for login) via Mailgun for reliable deliverability.
-### 1. Sign up for Mailgun
-Sign up at [mailgun.com](https://www.mailgun.com/).
+### Setup Steps
-### 2. Add Domain
-Add `podcastitlater.com` (or `mg.podcastitlater.com`) to Mailgun.
-We recommend using the root domain `podcastitlater.com` if you want emails to come from `@podcastitlater.com`.
+1. **Add domain to Mailgun**: Add `bensima.com` at [mailgun.com](https://app.mailgun.com/mg/sending/new)
-### 3. Configure DNS
-Mailgun will provide DNS records to verify the domain and authorize email sending. You must add these to your DNS provider (e.g., Cloudflare, Namecheap).
+2. **Configure DNS**: Add the records Mailgun provides:
+ - **TXT** (SPF): Update existing to include `include:mailgun.org`
+ - **TXT** (DKIM): Add the DKIM record Mailgun provides
+ - **CNAME** (tracking, optional): For open/click tracking
-Required records usually include:
-- **TXT** (SPF): `v=spf1 include:mailgun.org ~all`
-- **TXT** (DKIM): `k=rsa; p=...` (Provided by Mailgun)
-- **MX** (if receiving email, optional for just sending): `10 mxa.mailgun.org`, `10 mxb.mailgun.org`
-- **CNAME** (for tracking, optional): `email.podcastitlater.com` -> `mailgun.org`
+3. **Get SMTP credentials**: Go to Sending → Domain Settings → SMTP Credentials
-### 4. Verify Domain
-Click "Verify DNS Settings" in Mailgun dashboard. This may take up to 24 hours but is usually instant.
-
-### 5. Generate API Key / SMTP Credentials
-Go to "Sending" -> "Domain Settings" -> "SMTP Credentials".
-Create a new SMTP user (e.g., `postmaster@podcastitlater.com`).
-**Save the password immediately.**
-
-### 6. Update Secrets
-Update the production secrets file on the server (`/run/podcastitlater/env`):
-
-```bash
-SMTP_SERVER=smtp.mailgun.org
-SMTP_PASSWORD=your-new-smtp-password
-EMAIL_FROM=noreply@podcastitlater.com
-```
+4. **Update production secrets** in `/run/podcastitlater/env`:
+ ```bash
+ EMAIL_FROM=noreply@bensima.com
+ SMTP_SERVER=smtp.mailgun.org
+ SMTP_PORT=587
+ SMTP_PASSWORD=your-mailgun-smtp-password
+ ```
diff --git a/Biz/PodcastItLater/UI.py b/Biz/PodcastItLater/UI.py
index e9ef27d..d4b60a9 100644
--- a/Biz/PodcastItLater/UI.py
+++ b/Biz/PodcastItLater/UI.py
@@ -207,6 +207,14 @@ class PageLayout(Component[AnyChildren, PageLayoutAttrs]):
classes=["dropdown-item"],
),
),
+ html.li(
+ html.a(
+ html.i(classes=["bi", "bi-chat-heart-fill", "me-2"]),
+ "Feedback",
+ href="/admin/feedback",
+ classes=["dropdown-item"],
+ ),
+ ),
classes=["dropdown-menu"],
aria_labelledby="adminDropdown",
),
@@ -531,7 +539,6 @@ class AccountPage(Component[AnyChildren, AccountPageAttrs]):
classes=[
"card",
"card-body",
- "bg-light",
],
),
classes=["mb-5"],
@@ -621,6 +628,245 @@ class PricingPageAttrs(Attrs):
user: dict[str, typing.Any] | None
+class FeedbackFormAttrs(Attrs):
+ """Attributes for FeedbackForm component."""
+
+ campaign_id: str | None
+ success: bool
+
+
+class FeedbackForm(Component[AnyChildren, FeedbackFormAttrs]):
+ """Feedback collection form component."""
+
+ @override
+ def render(self) -> html.div:
+ campaign_id = self.attrs.get("campaign_id")
+ success = self.attrs.get("success", False)
+
+ if success:
+ return html.div(
+ html.div(
+ html.div(
+ html.h3(
+ html.i(
+ classes=["bi", "bi-check-circle-fill", "me-2"]
+ ),
+ "Thank You!",
+ classes=["text-success", "mb-3"],
+ ),
+ html.p(
+ "Your feedback has been submitted. "
+ "We really appreciate you taking the time to help us "
+ "improve PodcastItLater.",
+ classes=["lead"],
+ ),
+ html.a(
+ html.i(classes=["bi", "bi-arrow-left", "me-2"]),
+ "Back to home",
+ href="/",
+ classes=["btn", "btn-primary", "mt-3"],
+ ),
+ classes=["card-body", "text-center", "py-5"],
+ ),
+ classes=["card", "shadow-sm"],
+ ),
+ )
+
+ source_options = [
+ ("", "Select one..."),
+ ("outreach", "Email from Ben/Ava"),
+ ("search", "Search engine"),
+ ("social", "Social media"),
+ ("friend", "Friend/colleague"),
+ ("other", "Other"),
+ ]
+
+ return html.div(
+ html.div(
+ html.div(
+ html.h3(
+ html.i(classes=["bi", "bi-chat-heart-fill", "me-2"]),
+ "Share Your Feedback",
+ classes=["card-title", "mb-4"],
+ ),
+ html.p(
+ "Help us make PodcastItLater better! "
+ "All fields are optional.",
+ classes=["text-muted", "mb-4"],
+ ),
+ html.form(
+ html.input(
+ type="hidden",
+ name="campaign_id",
+ value=campaign_id or "",
+ )
+ if campaign_id
+ else html.div(),
+ # Email
+ html.div(
+ html.label(
+ "Email (optional)",
+ for_="email",
+ classes=["form-label"],
+ ),
+ html.input(
+ type="email",
+ name="email",
+ id="email",
+ placeholder="you@example.com",
+ classes=["form-control"],
+ ),
+ html.div(
+ "If you'd like us to follow up with you",
+ classes=["form-text"],
+ ),
+ classes=["mb-3"],
+ ),
+ # Source dropdown
+ html.div(
+ html.label(
+ "How did you hear about us?",
+ for_="source",
+ classes=["form-label"],
+ ),
+ html.select(
+ *[
+ html.option(label, value=value)
+ for value, label in source_options
+ ],
+ name="source",
+ id="source",
+ classes=["form-select"],
+ ),
+ classes=["mb-3"],
+ ),
+ # Use case
+ html.div(
+ html.label(
+ "What would you use PodcastItLater for?",
+ for_="use_case",
+ classes=["form-label"],
+ ),
+ html.textarea(
+ name="use_case",
+ id="use_case",
+ rows=3,
+ placeholder=(
+ "e.g., catching up on articles during commute, "
+ "listening to research papers while exercising..."
+ ),
+ classes=["form-control"],
+ ),
+ classes=["mb-3"],
+ ),
+ # General feedback
+ html.div(
+ html.label(
+ "Any other feedback?",
+ for_="feedback_text",
+ classes=["form-label"],
+ ),
+ html.textarea(
+ name="feedback_text",
+ id="feedback_text",
+ rows=3,
+ placeholder="Suggestions, issues, feature requests...",
+ classes=["form-control"],
+ ),
+ classes=["mb-3"],
+ ),
+ # Rating
+ html.div(
+ html.label(
+ "How likely are you to recommend PIL? (1-5)",
+ for_="rating",
+ classes=["form-label"],
+ ),
+ html.div(
+ *[
+ html.div(
+ html.input(
+ type="radio",
+ name="rating",
+ id=f"rating{i}",
+ value=str(i),
+ classes=["btn-check"],
+ ),
+ html.label(
+ str(i),
+ for_=f"rating{i}",
+ classes=[
+ "btn",
+ "btn-outline-primary",
+ ],
+ ),
+ classes=["me-2"],
+ )
+ for i in range(1, 6)
+ ],
+ classes=["d-flex"],
+ ),
+ html.div(
+ "1 = Not likely, 5 = Very likely",
+ classes=["form-text"],
+ ),
+ classes=["mb-4"],
+ ),
+ # Submit
+ html.button(
+ html.i(classes=["bi", "bi-send-fill", "me-2"]),
+ "Submit Feedback",
+ type="submit",
+ classes=["btn", "btn-primary", "btn-lg"],
+ ),
+ action="/feedback",
+ method="post",
+ ),
+ classes=["card-body", "p-4"],
+ ),
+ classes=["card", "shadow-sm"],
+ ),
+ )
+
+
+class FeedbackPageAttrs(Attrs):
+ """Attributes for FeedbackPage component."""
+
+ user: dict[str, typing.Any] | None
+ campaign_id: str | None
+ success: bool
+
+
+class FeedbackPage(Component[AnyChildren, FeedbackPageAttrs]):
+ """Feedback page with layout."""
+
+ @override
+ def render(self) -> PageLayout:
+ user = self.attrs.get("user")
+ campaign_id = self.attrs.get("campaign_id")
+ success = self.attrs.get("success", False)
+
+ return PageLayout(
+ html.div(
+ html.div(
+ html.div(
+ FeedbackForm(
+ campaign_id=campaign_id,
+ success=success,
+ ),
+ classes=["col-lg-8", "mx-auto"],
+ ),
+ classes=["row"],
+ ),
+ ),
+ user=user,
+ current_page="feedback",
+ page_title="Feedback - PodcastItLater",
+ error=None,
+ meta_tags=[],
+ )
+
+
class PricingPage(Component[AnyChildren, PricingPageAttrs]):
"""Pricing page component."""
diff --git a/Biz/PodcastItLater/Web.nix b/Biz/PodcastItLater/Web.nix
index 7533ca4..0980f5b 100644
--- a/Biz/PodcastItLater/Web.nix
+++ b/Biz/PodcastItLater/Web.nix
@@ -5,7 +5,7 @@
...
}: let
cfg = config.services.podcastitlater-web;
- rootDomain = "podcastitlater.com";
+ rootDomain = "podcastitlater.bensima.com";
ports = import ../../Omni/Cloud/Ports.nix;
in {
options.services.podcastitlater-web = {
@@ -39,7 +39,7 @@ in {
# Manual step: create this file with secrets
# SECRET_KEY=your-secret-key-for-sessions
# SESSION_SECRET=your-session-secret
- # EMAIL_FROM=noreply@podcastitlater.com
+ # EMAIL_FROM=noreply@bensima.com
# SMTP_SERVER=smtp.mailgun.org
# SMTP_PASSWORD=your-smtp-password
# STRIPE_SECRET_KEY=sk_live_your_stripe_secret_key
diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py
index 30b5236..c472819 100644
--- a/Biz/PodcastItLater/Web.py
+++ b/Biz/PodcastItLater/Web.py
@@ -18,7 +18,7 @@ Provides ludic + htmx interface and RSS feed generation.
# : dep stripe
# : dep sqids
import Biz.EmailAgent
-import Biz.PodcastItLater.Admin as Admin
+import Biz.PodcastItLater.Admin.Core as Admin
import Biz.PodcastItLater.Billing as Billing
import Biz.PodcastItLater.Core as Core
import Biz.PodcastItLater.Episode as Episode
@@ -28,7 +28,6 @@ import httpx
import logging
import ludic.html as html
import Omni.App as App
-import Omni.Log as Log
import Omni.Test as Test
import os
import pathlib
@@ -57,7 +56,9 @@ from typing import override
from unittest.mock import patch
logger = logging.getLogger(__name__)
-Log.setup(logger)
+logging.basicConfig(
+ level=logging.INFO, format="%(levelname)s: %(name)s: %(message)s"
+)
# Configuration
@@ -86,9 +87,10 @@ def decode_episode_id(sqid: str) -> int | None:
# Authentication configuration
MAGIC_LINK_MAX_AGE = 3600 # 1 hour
SESSION_MAX_AGE = 30 * 24 * 3600 # 30 days
-EMAIL_FROM = os.getenv("EMAIL_FROM", "noreply@podcastitlater.com")
+EMAIL_FROM = os.getenv("EMAIL_FROM", "noreply@bensima.com")
SMTP_SERVER = os.getenv("SMTP_SERVER", "smtp.mailgun.org")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
+SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
# Initialize serializer for magic links
magic_link_serializer = URLSafeTimedSerializer(
@@ -167,7 +169,8 @@ def send_magic_link(email: str, token: str) -> None:
# Create email body
magic_link = f"{BASE_URL}/auth/verify?token={token}"
- body_text_path.write_text(f"""
+ body_text_path.write_text(
+ f"""
Hello,
Click this link to login to PodcastItLater:
@@ -179,7 +182,9 @@ If you didn't request this, please ignore this email.
Best,
PodcastItLater
-""")
+""",
+ encoding="utf-8",
+ )
try:
Biz.EmailAgent.send_email(
@@ -189,6 +194,7 @@ PodcastItLater
password=SMTP_PASSWORD,
subject=subject,
body_text=body_text_path,
+ port=SMTP_PORT,
)
finally:
# Clean up temporary file
@@ -900,20 +906,126 @@ class HomePage(Component[AnyChildren, HomePageAttrs]):
error = self.attrs.get("error")
if not user:
- # Show public feed with login form for logged-out users
+ # Marketing landing page for logged-out users
return UI.PageLayout(
- LoginForm(error=error),
+ # Hero section
html.div(
- html.h4(
- html.i(classes=["bi", "bi-broadcast", "me-2"]),
- "Public Feed",
- classes=["mb-3", "mt-4"],
+ html.h1(
+ "Your Reading List, as a Podcast",
+ classes=["display-5", "fw-bold", "mb-3"],
),
html.p(
- "Featured articles converted to audio. "
- "Sign up to create your own personal feed!",
- classes=["text-muted", "mb-3"],
+ "Natural-sounding audio for your articles. "
+ "Delivered to your podcast app. "
+ "Listen while commuting, cooking, or working out.",
+ classes=["lead", "text-muted", "mb-4"],
+ ),
+ html.a(
+ "Start Listening",
+ href="#login",
+ classes=["btn", "btn-primary", "btn-lg"],
),
+ classes=["text-center", "py-5", "mb-5"],
+ ),
+ # How it works section
+ html.div(
+ html.h3(
+ "How It Works",
+ classes=["text-center", "mb-4"],
+ ),
+ html.div(
+ html.div(
+ html.div(
+ html.div(
+ html.span(
+ "1",
+ classes=[
+ "badge",
+ "bg-primary",
+ "rounded-circle",
+ "fs-5",
+ "mb-3",
+ ],
+ ),
+ html.h5("Paste any article URL"),
+ html.p(
+ "Copy the link to any article you want to listen to.",
+ classes=["text-muted", "small"],
+ ),
+ classes=["card-body", "text-center"],
+ ),
+ classes=["card", "h-100"],
+ ),
+ classes=["col-md-4", "mb-3"],
+ ),
+ html.div(
+ html.div(
+ html.div(
+ html.span(
+ "2",
+ classes=[
+ "badge",
+ "bg-primary",
+ "rounded-circle",
+ "fs-5",
+ "mb-3",
+ ],
+ ),
+ html.h5("We convert it to audio"),
+ html.p(
+ "Our AI reads the article in a natural, human-sounding voice and creates a podcast episode.",
+ classes=["text-muted", "small"],
+ ),
+ classes=["card-body", "text-center"],
+ ),
+ classes=["card", "h-100"],
+ ),
+ classes=["col-md-4", "mb-3"],
+ ),
+ html.div(
+ html.div(
+ html.div(
+ html.span(
+ "3",
+ classes=[
+ "badge",
+ "bg-primary",
+ "rounded-circle",
+ "fs-5",
+ "mb-3",
+ ],
+ ),
+ html.h5("Listen in your podcast app"),
+ html.p(
+ "Subscribe to your personal RSS feed in any podcast app.",
+ classes=["text-muted", "small"],
+ ),
+ classes=["card-body", "text-center"],
+ ),
+ classes=["card", "h-100"],
+ ),
+ classes=["col-md-4", "mb-3"],
+ ),
+ classes=["row"],
+ ),
+ classes=["mb-5"],
+ ),
+ # Social proof section
+ html.div(
+ html.h4(
+ "Join others who've converted their reading backlog",
+ classes=["text-center", "mb-4"],
+ ),
+ classes=["mb-4"],
+ ),
+ # Login form
+ html.div(
+ LoginForm(error=error),
+ id="login",
+ classes=["mb-5"],
+ ),
+ # Recent episodes
+ html.div(
EpisodeList(
episodes=episodes,
rss_url=None,
@@ -1033,6 +1145,83 @@ def pricing(request: Request) -> UI.PricingPage:
)
+@app.get("/feedback")
+def feedback_form(request: Request) -> UI.FeedbackPage:
+ """Display feedback form."""
+ user_id = request.session.get("user_id")
+ user = Core.Database.get_user_by_id(user_id) if user_id else None
+ campaign_id = request.query_params.get("campaign")
+
+ return UI.FeedbackPage(
+ user=user,
+ campaign_id=campaign_id,
+ success=False,
+ )
+
+
+@app.post("/feedback")
+async def submit_feedback(request: Request) -> UI.FeedbackPage:
+ """Submit feedback form."""
+ import secrets
+
+ user_id = request.session.get("user_id")
+ user = Core.Database.get_user_by_id(user_id) if user_id else None
+
+ form_data = await request.form()
+
+ email = form_data.get("email")
+ source = form_data.get("source")
+ campaign_id = form_data.get("campaign_id")
+ rating_str = form_data.get("rating")
+ feedback_text = form_data.get("feedback_text")
+ use_case = form_data.get("use_case")
+
+ rating = int(str(rating_str)) if rating_str else None
+
+ feedback_id = secrets.token_urlsafe(16)
+
+ Core.Database.create_feedback(
+ feedback_id=feedback_id,
+ email=str(email) if email else None,
+ source=str(source) if source else None,
+ campaign_id=str(campaign_id) if campaign_id else None,
+ rating=rating,
+ feedback_text=str(feedback_text) if feedback_text else None,
+ use_case=str(use_case) if use_case else None,
+ )
+
+ logger.info(
+ "Received feedback %s from %s", feedback_id, email or "anonymous"
+ )
+
+ return UI.FeedbackPage(
+ user=user,
+ campaign_id=None,
+ success=True,
+ )
+
+
+@app.get("/api/feedback")
+def api_feedback(request: Request) -> Response:
+ """Return feedback entries as JSON for agent tools."""
+ import json
+
+ limit_str = request.query_params.get("limit", "20")
+ since = request.query_params.get("since")
+
+ try:
+ limit = min(100, max(1, int(limit_str)))
+ except ValueError:
+ limit = 20
+
+ feedback = Core.Database.get_feedback(limit=limit, since=since)
+
+ return Response(
+ json.dumps(feedback),
+ media_type="application/json",
+ )
+
+
@app.post("/upgrade")
def upgrade(request: Request) -> RedirectResponse:
"""Start upgrade checkout flow."""
@@ -1832,6 +2021,7 @@ def cancel_queue_item(request: Request, job_id: int) -> Response:
app.delete("/queue/{job_id}")(Admin.delete_queue_item)
app.get("/admin/users")(Admin.admin_users)
app.get("/admin/metrics")(Admin.admin_metrics)
+app.get("/admin/feedback")(Admin.admin_feedback)
app.post("/admin/users/{user_id}/status")(Admin.update_user_status)
app.post("/admin/episode/{episode_id}/toggle-public")(
Admin.toggle_episode_public,
diff --git a/Biz/PodcastItLater/Worker.py b/Biz/PodcastItLater/Worker/Core.py
index ecef2c0..e536785 100644
--- a/Biz/PodcastItLater/Worker.py
+++ b/Biz/PodcastItLater/Worker/Core.py
@@ -18,7 +18,6 @@ import Biz.PodcastItLater.Worker.TextProcessing as TextProcessing
import json
import logging
import Omni.App as App
-import Omni.Log as Log
import Omni.Test as Test
import os
import pytest
@@ -32,7 +31,9 @@ from datetime import timezone
from typing import Any
logger = logging.getLogger(__name__)
-Log.setup(logger)
+logging.basicConfig(
+ level=logging.INFO, format="%(levelname)s: %(name)s: %(message)s"
+)
# Configuration from environment variables
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
diff --git a/Biz/PodcastItLater/Worker/Jobs.py b/Biz/PodcastItLater/Worker/Jobs.py
index 630aaf0..3511b63 100644
--- a/Biz/PodcastItLater/Worker/Jobs.py
+++ b/Biz/PodcastItLater/Worker/Jobs.py
@@ -179,7 +179,7 @@ class TestJobProcessing(Test.TestCase):
def setUp(self) -> None:
"""Set up test environment."""
# Import here to avoid circular dependencies
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
Core.Database.init_db()
diff --git a/Biz/PodcastItLater/Worker/Processor.py b/Biz/PodcastItLater/Worker/Processor.py
index bdda3e5..9d3b61f 100644
--- a/Biz/PodcastItLater/Worker/Processor.py
+++ b/Biz/PodcastItLater/Worker/Processor.py
@@ -865,7 +865,7 @@ class TestTextToSpeech(Test.TestCase):
def test_tts_generation(self) -> None:
"""Generate audio from text."""
# Import ShutdownHandler dynamically to avoid circular import
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
# Mock the export to write test audio data
def mock_export(buffer: io.BytesIO, **_kwargs: typing.Any) -> None:
@@ -901,7 +901,7 @@ class TestTextToSpeech(Test.TestCase):
def test_tts_chunking(self) -> None:
"""Handle long articles with chunking."""
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
long_text = "Long content " * 1000
chunks = ["Chunk 1", "Chunk 2", "Chunk 3"]
@@ -945,7 +945,7 @@ class TestTextToSpeech(Test.TestCase):
def test_tts_empty_text(self) -> None:
"""Handle empty input."""
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
with unittest.mock.patch(
"Biz.PodcastItLater.Worker.TextProcessing.prepare_text_for_tts",
@@ -960,7 +960,7 @@ class TestTextToSpeech(Test.TestCase):
def test_tts_special_characters(self) -> None:
"""Handle unicode and special chars."""
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
special_text = 'Unicode: 你好世界 Émojis: 🎙️📰 Special: <>&"'
@@ -1029,7 +1029,7 @@ class TestTextToSpeech(Test.TestCase):
def test_chunk_concatenation(self) -> None:
"""Verify audio joining."""
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
# Mock multiple audio segments
chunks = ["Chunk 1", "Chunk 2"]
@@ -1069,7 +1069,7 @@ class TestTextToSpeech(Test.TestCase):
def test_parallel_tts_generation(self) -> None:
"""Test parallel TTS processing."""
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
chunks = ["Chunk 1", "Chunk 2", "Chunk 3", "Chunk 4"]
@@ -1128,7 +1128,7 @@ class TestTextToSpeech(Test.TestCase):
def test_parallel_tts_high_memory_fallback(self) -> None:
"""Test fallback to serial processing when memory is high."""
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
chunks = ["Chunk 1", "Chunk 2"]
@@ -1171,7 +1171,7 @@ class TestTextToSpeech(Test.TestCase):
@staticmethod
def test_parallel_tts_error_handling() -> None:
"""Test error handling in parallel TTS processing."""
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
chunks = ["Chunk 1", "Chunk 2"]
@@ -1208,7 +1208,7 @@ class TestTextToSpeech(Test.TestCase):
def test_parallel_tts_order_preservation(self) -> None:
"""Test that chunks are combined in the correct order."""
- import Biz.PodcastItLater.Worker as Worker
+ import Biz.PodcastItLater.Worker.Core as Worker
chunks = ["First", "Second", "Third", "Fourth", "Fifth"]