Files
periscope/backend/routers/feedback.py
T
Siddharth Kothari 6672d2be57 Pinscope open-source core
Agentic schematic validation: datasheet extraction via Claude Console
Skills, netlist/BOM design graph, per-IC direct datasheet review with
page citations, capacitor derating, Next.js report UI.

Extracted from the Pinscope cloud codebase. Auth and billing live in the
private gateway repo behind stable seams (billing_hook.py, adapter files
listed in CLAUDE.md).
2026-07-16 21:29:45 -07:00

304 lines
9.5 KiB
Python

"""User feedback / ticket system.
Users can submit feedback tickets (bugs, rule reports, feature requests).
Tickets are stored as individual JSON files with JSONL indexes for fast listing.
Storage layout:
admin/feedback/tickets/{ticket_id}.json
admin/feedback/index/by_user/{user_id}.jsonl
admin/feedback/index/by_project/{project_id}.jsonl
admin/feedback/index/all.jsonl
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Literal
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from backend.routers.deps import get_storage, get_user_id
from backend.services.storage import StorageBackend
logger = logging.getLogger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# Storage key helpers
# ---------------------------------------------------------------------------
_TICKETS_PREFIX = "admin/feedback/tickets/"
_INDEX_BY_USER = "admin/feedback/index/by_user/"
_INDEX_BY_PROJECT = "admin/feedback/index/by_project/"
_INDEX_ALL = "admin/feedback/index/all.jsonl"
def _ticket_key(ticket_id: str) -> str:
return f"{_TICKETS_PREFIX}{ticket_id}.json"
def _user_index_key(user_id: str) -> str:
return f"{_INDEX_BY_USER}{user_id}.jsonl"
def _project_index_key(project_id: str) -> str:
return f"{_INDEX_BY_PROJECT}{project_id}.jsonl"
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
FeedbackType = Literal["bug", "rule_feedback", "feature_request"]
FeedbackStatus = Literal["open", "acknowledged", "resolved"]
class FeedbackTicket(BaseModel):
ticket_id: str
user_id: str
user_name: str | None = None
user_email: str | None = None
project_id: str | None = None
project_name: str | None = None
type: FeedbackType
status: FeedbackStatus = "open"
finding_id: str | None = None
finding_text: str | None = None
finding_designator: str | None = None
finding_mpn: str | None = None
finding_status: str | None = None
message: str
admin_notes: str | None = None
created_at: str
updated_at: str
class CreateFeedbackRequest(BaseModel):
type: FeedbackType
message: str = Field(..., min_length=1, max_length=5000)
project_id: str | None = None
project_name: str | None = None
user_name: str | None = None
user_email: str | None = None
finding_id: str | None = None
finding_text: str | None = None
finding_designator: str | None = None
finding_mpn: str | None = None
finding_status: str | None = None
class UpdateFeedbackRequest(BaseModel):
status: FeedbackStatus | None = None
admin_notes: str | None = None
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _append_index(storage: StorageBackend, key: str, entry: dict) -> None:
existing = ""
if storage.exists(key):
existing = storage.read_text(key)
line = json.dumps(entry) + "\n"
storage.write_text(key, existing + line)
def _read_index(storage: StorageBackend, key: str) -> list[dict]:
if not storage.exists(key):
return []
text = storage.read_text(key)
entries: list[dict] = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
return entries
def _read_ticket(storage: StorageBackend, ticket_id: str) -> FeedbackTicket | None:
key = _ticket_key(ticket_id)
if not storage.exists(key):
return None
try:
data = storage.read_json(key)
return FeedbackTicket(**data)
except Exception:
logger.warning("Failed to read ticket %s", ticket_id)
return None
def _read_tickets_from_index(
storage: StorageBackend,
index_key: str,
*,
status: str | None = None,
ticket_type: str | None = None,
project_id: str | None = None,
) -> list[FeedbackTicket]:
index_entries = _read_index(storage, index_key)
tickets: list[FeedbackTicket] = []
for entry in reversed(index_entries):
tid = entry.get("ticket_id")
if not tid:
continue
ticket = _read_ticket(storage, tid)
if not ticket:
continue
if status and ticket.status != status:
continue
if ticket_type and ticket.type != ticket_type:
continue
if project_id and ticket.project_id != project_id:
continue
tickets.append(ticket)
return tickets
# ---------------------------------------------------------------------------
# User endpoints
# ---------------------------------------------------------------------------
@router.post("/feedback", response_model=FeedbackTicket)
async def create_feedback(body: CreateFeedbackRequest, request: Request):
storage = get_storage(request)
user_id = get_user_id(request)
now = datetime.now(timezone.utc).isoformat()
ticket_id = uuid.uuid4().hex[:12]
ticket = FeedbackTicket(
ticket_id=ticket_id,
user_id=user_id,
user_name=body.user_name,
user_email=body.user_email,
project_id=body.project_id,
project_name=body.project_name,
type=body.type,
status="open",
finding_id=body.finding_id,
finding_text=body.finding_text,
finding_designator=body.finding_designator,
finding_mpn=body.finding_mpn,
finding_status=body.finding_status,
message=body.message,
admin_notes=None,
created_at=now,
updated_at=now,
)
storage.write_json(_ticket_key(ticket_id), ticket.model_dump())
index_entry = {"ticket_id": ticket_id, "created_at": now}
_append_index(storage, _user_index_key(user_id), index_entry)
_append_index(storage, _INDEX_ALL, index_entry)
if body.project_id:
_append_index(storage, _project_index_key(body.project_id), index_entry)
# Notify the admin inbox (fire-and-forget, identical pattern to
# pipeline-started). Errors are swallowed inside the email service.
try:
from backend.services.email import send_feedback_received_email
await send_feedback_received_email(
ticket_id=ticket_id,
user_id=user_id,
feedback_type=body.type,
message=body.message,
submitter_name=body.user_name,
submitter_email=body.user_email,
project_name=body.project_name,
project_id=body.project_id,
finding_designator=body.finding_designator,
finding_mpn=body.finding_mpn,
finding_status=body.finding_status,
finding_text=body.finding_text,
)
except Exception:
logger.exception("Failed to enqueue feedback-received email for %s", ticket_id)
return ticket
@router.get("/feedback", response_model=list[FeedbackTicket])
async def list_my_feedback(request: Request, status: str | None = None):
storage = get_storage(request)
user_id = get_user_id(request)
return _read_tickets_from_index(
storage, _user_index_key(user_id), status=status,
)
# ---------------------------------------------------------------------------
# Admin endpoints
# ---------------------------------------------------------------------------
@router.get("/admin/feedback", response_model=list[FeedbackTicket])
async def list_all_feedback(
request: Request,
status: str | None = None,
type: str | None = None,
project_id: str | None = None,
):
from backend.routers.admin import _require_admin
await _require_admin(request)
storage = get_storage(request)
return _read_tickets_from_index(
storage, _INDEX_ALL, status=status, ticket_type=type, project_id=project_id,
)
@router.put("/admin/feedback/{ticket_id}", response_model=FeedbackTicket)
async def update_feedback(ticket_id: str, body: UpdateFeedbackRequest, request: Request):
from backend.routers.admin import _require_admin
await _require_admin(request)
storage = get_storage(request)
ticket = _read_ticket(storage, ticket_id)
if not ticket:
raise HTTPException(404, "Ticket not found")
prev_admin_notes = (ticket.admin_notes or "").strip()
if body.status is not None:
ticket.status = body.status
if body.admin_notes is not None:
ticket.admin_notes = body.admin_notes
ticket.updated_at = datetime.now(timezone.utc).isoformat()
storage.write_json(_ticket_key(ticket_id), ticket.model_dump())
# If admin_notes changed to a new, non-empty value, notify the submitter.
new_admin_notes = (ticket.admin_notes or "").strip()
if new_admin_notes and new_admin_notes != prev_admin_notes:
try:
from backend.services.email import send_feedback_reply_email
await send_feedback_reply_email(
user_id=ticket.user_id,
reply_text=new_admin_notes,
original_message=ticket.message,
recipient_name=ticket.user_name,
recipient_email=ticket.user_email,
project_name=ticket.project_name,
finding_designator=ticket.finding_designator,
finding_mpn=ticket.finding_mpn,
)
except Exception:
logger.exception(
"Failed to enqueue feedback-reply email for ticket %s", ticket_id
)
return ticket