summaryrefslogtreecommitdiff
path: root/Biz
diff options
context:
space:
mode:
authorBen Sima <ben@bensima.com>2025-12-15 08:47:02 -0500
committerBen Sima <ben@bensima.com>2025-12-15 08:47:02 -0500
commit0baab1972e30c0e4629e67152838e660b02a2537 (patch)
treed82d9402e4a0840777ee3d4e39ab4329f246918b /Biz
parentadf693eb82cddd2c383cdebd3392716446ddf054 (diff)
t-265.6: Add feedback collection endpoint for PIL
- Add feedback table with migration in Core.py - Add FeedbackForm and FeedbackPage UI components - Add /feedback GET/POST routes and /api/feedback JSON endpoint - Add admin feedback view at /admin/feedback - Create Omni/Agent/Tools/Feedback.hs with feedback_list tool - Wire feedback tool into Telegram agent
Diffstat (limited to 'Biz')
-rw-r--r--Biz/PodcastItLater/Admin.py6
-rw-r--r--Biz/PodcastItLater/Admin/Handlers.py28
-rw-r--r--Biz/PodcastItLater/Admin/Views.py89
-rw-r--r--Biz/PodcastItLater/Core.py144
-rw-r--r--Biz/PodcastItLater/UI.py247
-rw-r--r--Biz/PodcastItLater/Web.py85
6 files changed, 597 insertions, 2 deletions
diff --git a/Biz/PodcastItLater/Admin.py b/Biz/PodcastItLater/Admin.py
index 29332df..10ea7f6 100644
--- a/Biz/PodcastItLater/Admin.py
+++ b/Biz/PodcastItLater/Admin.py
@@ -40,7 +40,10 @@ 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
@@ -52,6 +55,8 @@ update_user_status = Handlers.update_user_status
__all__ = [
"ActionButtons",
"ActionButtonsAttrs",
+ "AdminFeedback",
+ "AdminFeedbackAttrs",
"AdminUsers",
"AdminUsersAttrs",
"AdminView",
@@ -72,6 +77,7 @@ __all__ = [
"TruncatedTextAttrs",
"UserTableRow",
"UserTableRowAttrs",
+ "admin_feedback",
"admin_metrics",
"admin_queue_status",
"admin_users",
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..7834340 100644
--- a/Biz/PodcastItLater/Admin/Views.py
+++ b/Biz/PodcastItLater/Admin/Views.py
@@ -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/Core.py b/Biz/PodcastItLater/Core.py
index 62eead8..d0ed2f0 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:
@@ -1201,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 cursor.fetchone()["count"]
+
+ @staticmethod
def get_metrics_summary() -> dict[str, Any]:
"""Get aggregate metrics summary for admin dashboard.
diff --git a/Biz/PodcastItLater/UI.py b/Biz/PodcastItLater/UI.py
index e9ef27d..b243ae7 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",
),
@@ -621,6 +629,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", # type: ignore[call-arg]
+ 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", # type: ignore[call-arg]
+ 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.py b/Biz/PodcastItLater/Web.py
index 30b5236..076eb95 100644
--- a/Biz/PodcastItLater/Web.py
+++ b/Biz/PodcastItLater/Web.py
@@ -167,7 +167,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 +180,9 @@ If you didn't request this, please ignore this email.
Best,
PodcastItLater
-""")
+""",
+ encoding="utf-8",
+ )
try:
Biz.EmailAgent.send_email(
@@ -1033,6 +1036,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(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 +1912,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,