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).
304 lines
9.5 KiB
Python
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
|