diff options
| author | Ben Sima <ben@bensima.com> | 2025-12-15 08:47:02 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-12-15 08:47:02 -0500 |
| commit | 0baab1972e30c0e4629e67152838e660b02a2537 (patch) | |
| tree | d82d9402e4a0840777ee3d4e39ab4329f246918b /Biz | |
| parent | adf693eb82cddd2c383cdebd3392716446ddf054 (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.py | 6 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Admin/Handlers.py | 28 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Admin/Views.py | 89 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Core.py | 144 | ||||
| -rw-r--r-- | Biz/PodcastItLater/UI.py | 247 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Web.py | 85 |
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, |
