Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/spec-create.yml
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ jobs:

# Reserved spec slugs collide with top-level frontend routes.
# Keep this list in sync with `RESERVED_TOP_LEVEL` in app/src/utils/paths.ts.
RESERVED_SLUGS=(plots specs libraries palette about legal mcp stats debug api og sitemap.xml robots.txt)
RESERVED_SLUGS=(plots specs libraries palette about legal mcp stats debug map api og sitemap.xml robots.txt)
for reserved in "${RESERVED_SLUGS[@]}"; do
if [[ "$SPEC_ID" == "$reserved" ]]; then
echo "::error::Spec ID '$SPEC_ID' collides with reserved top-level route. Choose a different slug."
Expand Down
55 changes: 55 additions & 0 deletions alembic/versions/c5d7e9f1a3b2_add_feedback_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""add_feedback_table

Add `feedback` table for the in-app quick feedback widget (issue #5662).
Stores lightweight user remarks submitted via the floating widget on
anyplot.ai. Entries are immutable once written; triage is manual.

Revision ID: c5d7e9f1a3b2
Revises: 3a7e1b5c0c4f
Create Date: 2026-05-17

"""

from typing import Sequence

import sqlalchemy as sa

from alembic import op


# revision identifiers, used by Alembic.
revision: str = "c5d7e9f1a3b2"
down_revision: str | None = "3a7e1b5c0c4f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Create the feedback table plus indexes for recent-first browsing and rate-limit lookups."""
op.create_table(
"feedback",
sa.Column("id", sa.String(36), primary_key=True, nullable=False),
sa.Column("message", sa.String(500), nullable=False),
sa.Column("reaction", sa.String(20), nullable=True),
sa.Column("email", sa.String(255), nullable=True),
sa.Column("path", sa.String(500), nullable=True),
sa.Column("spec_id", sa.String(100), nullable=True),
sa.Column("user_agent", sa.String(500), nullable=True),
sa.Column("viewport", sa.String(20), nullable=True),
sa.Column("session_id", sa.String(64), nullable=True),
sa.Column("ip_hash", sa.String(64), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint(
"reaction IS NULL OR reaction IN ('thumbs_up','thumbs_down','bug','idea','heart')",
name="ck_feedback_reaction_valid",
),
)
op.create_index("ix_feedback_created_at", "feedback", [sa.text("created_at DESC")])
op.create_index("ix_feedback_ip_hash_created_at", "feedback", ["ip_hash", sa.text("created_at DESC")])


def downgrade() -> None:
"""Drop the feedback table and its indexes."""
op.drop_index("ix_feedback_ip_hash_created_at", table_name="feedback")
op.drop_index("ix_feedback_created_at", table_name="feedback")
op.drop_table("feedback")
45 changes: 45 additions & 0 deletions alembic/versions/d4e8a1f2c937_feedback_rename_email_to_contact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""feedback_rename_email_to_contact

Rename `feedback.email` to `feedback.contact`, drop the `heart` reaction from
the CHECK constraint, and make `message` nullable so a reaction-only entry is
accepted. The contact column is now free-form (name, email, handle, …)
instead of strictly an email address.

Revision ID: d4e8a1f2c937
Revises: c5d7e9f1a3b2
Create Date: 2026-05-18

"""

from typing import Sequence

import sqlalchemy as sa

from alembic import op


revision: str = "d4e8a1f2c937"
down_revision: str | None = "c5d7e9f1a3b2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
with op.batch_alter_table("feedback") as batch_op:
batch_op.alter_column("email", new_column_name="contact", existing_type=sa.String(255))
batch_op.alter_column("message", existing_type=sa.String(500), nullable=True)
batch_op.drop_constraint("ck_feedback_reaction_valid", type_="check")
batch_op.create_check_constraint(
"ck_feedback_reaction_valid", "reaction IS NULL OR reaction IN ('thumbs_up','thumbs_down','bug','idea')"
)


def downgrade() -> None:
with op.batch_alter_table("feedback") as batch_op:
batch_op.drop_constraint("ck_feedback_reaction_valid", type_="check")
batch_op.create_check_constraint(
"ck_feedback_reaction_valid",
"reaction IS NULL OR reaction IN ('thumbs_up','thumbs_down','bug','idea','heart')",
)
batch_op.alter_column("message", existing_type=sa.String(500), nullable=False)
batch_op.alter_column("contact", new_column_name="email", existing_type=sa.String(255))
60 changes: 60 additions & 0 deletions alembic/versions/e5b1c9d4a7f2_feedback_uuid_and_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""feedback_uuid_and_status

Two cleanups on the feedback table:

1. Fix `feedback.id` column type. PR #5662 created it as varchar(36), but the
model declares `UniversalUUID` which binds parameters as native UUID on
Postgres. Result: every read of a freshly-inserted row failed with
`operator does not exist: character varying = uuid`. We ALTER the column
to UUID on Postgres (SQLite is unaffected β€” its UniversalUUID impl maps
back to String, and these migrations don't run there).

2. Add a `status` column for admin triage from the debug page:
`new` (default) β†’ `in_progress` β†’ `done` or `wont_solve`. Constrained to
the allow-list via a CHECK constraint.

Revision ID: e5b1c9d4a7f2
Revises: d4e8a1f2c937
Create Date: 2026-05-18

"""

from typing import Sequence

import sqlalchemy as sa

from alembic import op


revision: str = "e5b1c9d4a7f2"
down_revision: str | None = "d4e8a1f2c937"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name == "postgresql":
# USING-cast is required because PG can't implicitly coerce varchar -> uuid.
op.execute("ALTER TABLE feedback ALTER COLUMN id TYPE uuid USING id::uuid")

with op.batch_alter_table("feedback") as batch_op:
batch_op.add_column(sa.Column("status", sa.String(20), nullable=False, server_default="new"))
batch_op.create_check_constraint(
"ck_feedback_status_valid", "status IN ('new','in_progress','done','wont_solve')"
)

# Drop the server default after the existing rows are backfilled β€” new
# writes will set status explicitly through the model default.
if bind.dialect.name == "postgresql":
op.execute("ALTER TABLE feedback ALTER COLUMN status DROP DEFAULT")


def downgrade() -> None:
with op.batch_alter_table("feedback") as batch_op:
batch_op.drop_constraint("ck_feedback_status_valid", type_="check")
batch_op.drop_column("status")

bind = op.get_bind()
if bind.dialect.name == "postgresql":
op.execute("ALTER TABLE feedback ALTER COLUMN id TYPE varchar(36) USING id::varchar")
2 changes: 2 additions & 0 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from api.routers import ( # noqa: E402
debug_router,
download_router,
feedback_router,
health_router,
insights_router,
languages_router,
Expand Down Expand Up @@ -153,6 +154,7 @@ async def add_cache_headers(request: Request, call_next):
app.include_router(og_images_router)
app.include_router(proxy_router)
app.include_router(debug_router)
app.include_router(feedback_router)


# ASGI middleware to handle /mcp without trailing slash
Expand Down
2 changes: 2 additions & 0 deletions api/routers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from api.routers.debug import router as debug_router
from api.routers.download import router as download_router
from api.routers.feedback import router as feedback_router
from api.routers.health import router as health_router
from api.routers.insights import router as insights_router
from api.routers.languages import router as languages_router
Expand All @@ -17,6 +18,7 @@
__all__ = [
"debug_router",
"download_router",
"feedback_router",
"health_router",
"insights_router",
"languages_router",
Expand Down
111 changes: 110 additions & 1 deletion api/routers/debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@

from api.cache import clear_cache, get_cache_stats
from api.dependencies import require_db
from api.exceptions import raise_validation_error
from core.config import settings
from core.constants import SUPPORTED_LIBRARIES
from core.database import SpecRepository
from core.database import FEEDBACK_REACTIONS, FEEDBACK_STATUSES, FeedbackRepository, SpecRepository


router = APIRouter(prefix="/debug", tags=["debug"])
Expand Down Expand Up @@ -505,3 +506,111 @@ async def invalidate_cache(x_cache_token: str | None = Header(default=None)) ->
return CacheInvalidateResponse(
cleared=stats_before["size"], maxsize=stats_before["maxsize"], ttl=stats_before["ttl"]
)


# ============================================================================
# Feedback analytics + triage (issue #5662 follow-up)
# ============================================================================


class FeedbackTopPage(BaseModel):
"""Aggregated count of one reaction per page path."""

path: str
count: int
last_seen: str | None # ISO timestamp of the most recent entry on this path


class FeedbackMessageItem(BaseModel):
"""A feedback entry with free-text message β€” surfaced to admins for triage."""

id: str
message: str
reaction: str | None
contact: str | None
path: str | None
spec_id: str | None
viewport: str | None
status: str
created_at: str # ISO timestamp


class FeedbackStatusUpdate(BaseModel):
"""Body of the PATCH endpoint."""

status: str


@router.get("/feedback/top", response_model=list[FeedbackTopPage], dependencies=[Depends(require_admin)])
async def feedback_top_pages(
reaction: str, limit: int = 20, db: AsyncSession = Depends(require_db)
) -> list[FeedbackTopPage]:
"""Top pages by reaction count (thumbs_up / thumbs_down / idea / bug)."""
if reaction not in FEEDBACK_REACTIONS:
raise_validation_error(f"reaction must be one of {FEEDBACK_REACTIONS}")
if not 1 <= limit <= 100:
raise_validation_error("limit must be between 1 and 100")
rows = await FeedbackRepository(db).top_paths_by_reaction(reaction, limit)
return [FeedbackTopPage(**row) for row in rows]


@router.get("/feedback/messages", response_model=list[FeedbackMessageItem], dependencies=[Depends(require_admin)])
async def feedback_messages(
status: str | None = None, limit: int = 50, db: AsyncSession = Depends(require_db)
) -> list[FeedbackMessageItem]:
"""List feedback entries that carry a free-text message, newest first.

`status="open"` is a pseudo-value mapping to `('new', 'in_progress')` β€” the
default view on /debug only shows the unresolved triage states.
"""
if not 1 <= limit <= 200:
raise_validation_error("limit must be between 1 and 200")
if status == "open":
repo_status: str | tuple[str, ...] | None = ("new", "in_progress")
elif status is None:
repo_status = None
elif status in FEEDBACK_STATUSES:
repo_status = status
else:
raise_validation_error(f"status must be one of {FEEDBACK_STATUSES} or 'open'")
entries = await FeedbackRepository(db).list_with_message(repo_status, limit)
return [
FeedbackMessageItem(
id=str(e.id),
message=e.message or "",
reaction=e.reaction,
contact=e.contact,
path=e.path,
spec_id=e.spec_id,
viewport=e.viewport,
status=e.status,
# feedback.created_at is TIMESTAMP WITHOUT TIME ZONE in UTC β€” tag with Z
# so the browser parses it as UTC, not local time (otherwise "just now"
# entries render as "Nh ago" depending on the user's offset).
created_at=e.created_at.replace(tzinfo=timezone.utc).isoformat(),
)
for e in entries
]


@router.patch("/feedback/{entry_id}", response_model=FeedbackMessageItem, dependencies=[Depends(require_admin)])
async def update_feedback_status(
entry_id: str, payload: FeedbackStatusUpdate, db: AsyncSession = Depends(require_db)
) -> FeedbackMessageItem:
"""Set the triage status on one feedback entry."""
if payload.status not in FEEDBACK_STATUSES:
raise_validation_error(f"status must be one of {FEEDBACK_STATUSES}")
updated = await FeedbackRepository(db).update_status(entry_id, payload.status)
if updated is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="feedback entry not found")
return FeedbackMessageItem(
id=str(updated.id),
message=updated.message or "",
reaction=updated.reaction,
contact=updated.contact,
path=updated.path,
spec_id=updated.spec_id,
viewport=updated.viewport,
status=updated.status,
created_at=updated.created_at.replace(tzinfo=timezone.utc).isoformat(),
)
Loading