From 14a509ddb5696c84d2a9b99dce1c1b29eb711a45 Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Tue, 23 Dec 2025 13:18:00 -0800 Subject: [PATCH] feat: Add bug tracker MVP (decoupled, feature-flagged) - Backend: Ticket and TicketComment models (no FK to User) - API: /tickets/* endpoints for submit, view, comment, upvote - Admin: /tickets/admin/* for triage queue - Frontend: /bugs pages (submit, my-tickets, known-issues, detail) - Feature flag: ENABLE_BUG_TRACKER env var (default: true) To disable: Set ENABLE_BUG_TRACKER=false To remove: Delete models_tickets.py, routers/tickets.py, frontend/app/bugs/ --- backend/main.py | 9 + backend/models_tickets.py | 140 +++++++++ backend/routers/tickets.py | 371 ++++++++++++++++++++++++ docs/BUGS_TRACKER_SPEC.md | 248 ++++++++++++++++ frontend/app/bugs/known-issues/page.tsx | 143 +++++++++ frontend/app/bugs/my-tickets/page.tsx | 146 ++++++++++ frontend/app/bugs/page.tsx | 299 +++++++++++++++++++ frontend/app/bugs/ticket/[id]/page.tsx | 241 +++++++++++++++ 8 files changed, 1597 insertions(+) create mode 100644 backend/models_tickets.py create mode 100644 backend/routers/tickets.py create mode 100644 docs/BUGS_TRACKER_SPEC.md create mode 100644 frontend/app/bugs/known-issues/page.tsx create mode 100644 frontend/app/bugs/my-tickets/page.tsx create mode 100644 frontend/app/bugs/page.tsx create mode 100644 frontend/app/bugs/ticket/[id]/page.tsx diff --git a/backend/main.py b/backend/main.py index 8f3be18..e286835 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,8 +1,12 @@ from fastapi import FastAPI +import os from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase, gamification, videos from fastapi.middleware.cors import CORSMiddleware +# Feature flags - set to False to disable features +ENABLE_BUG_TRACKER = os.getenv("ENABLE_BUG_TRACKER", "true").lower() == "true" + app = FastAPI() app.add_middleware( @@ -39,6 +43,11 @@ app.include_router(chase.router) app.include_router(gamification.router) app.include_router(videos.router) +# Optional features - can be disabled via env vars +if ENABLE_BUG_TRACKER: + from routers import tickets + app.include_router(tickets.router) + @app.get("/") def read_root(): return {"Hello": "World"} diff --git a/backend/models_tickets.py b/backend/models_tickets.py new file mode 100644 index 0000000..e109757 --- /dev/null +++ b/backend/models_tickets.py @@ -0,0 +1,140 @@ +""" +Bug Tracker Models - ISOLATED MODULE +No dependencies on main Elmeg models. +Can be removed by: deleting this file + routes file + removing router import from main.py +""" +from datetime import datetime +from typing import Optional, List +from sqlmodel import SQLModel, Field, Relationship +from enum import Enum + + +class TicketType(str, Enum): + BUG = "bug" + FEATURE = "feature" + QUESTION = "question" + OTHER = "other" + + +class TicketStatus(str, Enum): + OPEN = "open" + IN_PROGRESS = "in_progress" + RESOLVED = "resolved" + CLOSED = "closed" + + +class TicketPriority(str, Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class Ticket(SQLModel, table=True): + """ + Support ticket - fully decoupled from User model. + Stores reporter info as strings, not FKs. + """ + id: Optional[int] = Field(default=None, primary_key=True) + ticket_number: str = Field(unique=True, index=True) # ELM-001 + + type: TicketType = Field(default=TicketType.BUG) + status: TicketStatus = Field(default=TicketStatus.OPEN) + priority: TicketPriority = Field(default=TicketPriority.MEDIUM) + + title: str = Field(max_length=200) + description: str = Field(default="") + + # Reporter info - stored as strings, not FK + reporter_email: str = Field(index=True) + reporter_name: Optional[str] = None + reporter_user_id: Optional[int] = None # Reference only, not FK + + # Assignment - stored as strings + assigned_to_email: Optional[str] = None + assigned_to_name: Optional[str] = None + + is_public: bool = Field(default=False) + upvotes: int = Field(default=0) + + # Environment info + browser: Optional[str] = None + os: Optional[str] = None + page_url: Optional[str] = None + + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + resolved_at: Optional[datetime] = None + + # Relationships (within ticket system only) + comments: List["TicketComment"] = Relationship(back_populates="ticket") + + +class TicketComment(SQLModel, table=True): + """Comment on a ticket - no FK to User""" + id: Optional[int] = Field(default=None, primary_key=True) + ticket_id: int = Field(foreign_key="ticket.id") + + # Author info - stored as strings + author_email: str + author_name: str + author_user_id: Optional[int] = None # Reference only + + content: str + is_internal: bool = Field(default=False) # Admin-only visibility + + created_at: datetime = Field(default_factory=datetime.utcnow) + + # Relationship + ticket: Optional[Ticket] = Relationship(back_populates="comments") + + +# ============ Schemas ============ + +class TicketCreate(SQLModel): + type: TicketType = TicketType.BUG + priority: TicketPriority = TicketPriority.MEDIUM + title: str + description: str = "" + reporter_email: Optional[str] = None + reporter_name: Optional[str] = None + browser: Optional[str] = None + os: Optional[str] = None + page_url: Optional[str] = None + + +class TicketUpdate(SQLModel): + status: Optional[TicketStatus] = None + priority: Optional[TicketPriority] = None + assigned_to_email: Optional[str] = None + assigned_to_name: Optional[str] = None + is_public: Optional[bool] = None + + +class TicketCommentCreate(SQLModel): + content: str + + +class TicketRead(SQLModel): + id: int + ticket_number: str + type: TicketType + status: TicketStatus + priority: TicketPriority + title: str + description: str + reporter_email: str + reporter_name: Optional[str] + is_public: bool + upvotes: int + created_at: datetime + updated_at: datetime + resolved_at: Optional[datetime] + + +class TicketCommentRead(SQLModel): + id: int + author_name: str + content: str + is_internal: bool + created_at: datetime diff --git a/backend/routers/tickets.py b/backend/routers/tickets.py new file mode 100644 index 0000000..f47d958 --- /dev/null +++ b/backend/routers/tickets.py @@ -0,0 +1,371 @@ +""" +Bug Tracker API Routes - ISOLATED MODULE + +To remove this feature: +1. Delete this file +2. Delete models_tickets.py +3. Remove router import from main.py +4. Drop 'ticket' and 'ticketcomment' tables from database +""" +from typing import List, Optional +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlmodel import Session, select, func + +from database import get_session + +# Import auth helpers but don't create hard dependency +try: + from auth import get_current_user, get_current_user_optional + from models import User + AUTH_AVAILABLE = True +except ImportError: + AUTH_AVAILABLE = False + User = None + def get_current_user(): + return None + def get_current_user_optional(): + return None + +from models_tickets import ( + Ticket, TicketComment, TicketType, TicketStatus, TicketPriority, + TicketCreate, TicketUpdate, TicketCommentCreate, TicketRead, TicketCommentRead +) + +router = APIRouter(prefix="/tickets", tags=["tickets"]) + + +def generate_ticket_number(session: Session) -> str: + """Generate next ticket number like ELM-001""" + result = session.exec(select(func.count(Ticket.id))).one() + return f"ELM-{(result + 1):03d}" + + +# --- Public Endpoints --- + +@router.post("/", response_model=TicketRead) +def create_ticket( + ticket: TicketCreate, + session: Session = Depends(get_session), + current_user = Depends(get_current_user_optional) if AUTH_AVAILABLE else None +): + """Create a new support ticket""" + # Determine reporter info + if current_user: + reporter_email = current_user.email + reporter_name = current_user.email.split("@")[0] + reporter_user_id = current_user.id + elif ticket.reporter_email: + reporter_email = ticket.reporter_email + reporter_name = ticket.reporter_name or ticket.reporter_email.split("@")[0] + reporter_user_id = None + else: + raise HTTPException(status_code=400, detail="Email required for anonymous submissions") + + db_ticket = Ticket( + ticket_number=generate_ticket_number(session), + type=ticket.type, + priority=ticket.priority, + title=ticket.title, + description=ticket.description, + reporter_email=reporter_email, + reporter_name=reporter_name, + reporter_user_id=reporter_user_id, + browser=ticket.browser, + os=ticket.os, + page_url=ticket.page_url, + ) + + session.add(db_ticket) + session.commit() + session.refresh(db_ticket) + + return db_ticket + + +@router.get("/known-issues", response_model=List[TicketRead]) +def get_known_issues( + session: Session = Depends(get_session), + offset: int = 0, + limit: int = Query(default=50, le=100) +): + """Get public/known issues""" + tickets = session.exec( + select(Ticket) + .where(Ticket.is_public == True) + .where(Ticket.status.in_([TicketStatus.OPEN, TicketStatus.IN_PROGRESS])) + .order_by(Ticket.upvotes.desc(), Ticket.created_at.desc()) + .offset(offset) + .limit(limit) + ).all() + return tickets + + +@router.get("/my-tickets", response_model=List[TicketRead]) +def get_my_tickets( + session: Session = Depends(get_session), + current_user = Depends(get_current_user) if AUTH_AVAILABLE else None +): + """Get tickets submitted by current user""" + if not current_user: + raise HTTPException(status_code=401, detail="Login required") + + tickets = session.exec( + select(Ticket) + .where(Ticket.reporter_user_id == current_user.id) + .order_by(Ticket.created_at.desc()) + ).all() + return tickets + + +@router.get("/{ticket_number}", response_model=TicketRead) +def get_ticket( + ticket_number: str, + session: Session = Depends(get_session), + current_user = Depends(get_current_user_optional) if AUTH_AVAILABLE else None +): + """Get ticket by number""" + ticket = session.exec( + select(Ticket).where(Ticket.ticket_number == ticket_number) + ).first() + + if not ticket: + raise HTTPException(status_code=404, detail="Ticket not found") + + # Check visibility + if not ticket.is_public: + if not current_user: + raise HTTPException(status_code=403, detail="Login required") + is_admin = getattr(current_user, 'is_superuser', False) + if ticket.reporter_user_id != current_user.id and not is_admin: + raise HTTPException(status_code=403, detail="Access denied") + + return ticket + + +@router.get("/{ticket_number}/comments", response_model=List[TicketCommentRead]) +def get_ticket_comments( + ticket_number: str, + session: Session = Depends(get_session), + current_user = Depends(get_current_user_optional) if AUTH_AVAILABLE else None +): + """Get comments for a ticket""" + ticket = session.exec( + select(Ticket).where(Ticket.ticket_number == ticket_number) + ).first() + + if not ticket: + raise HTTPException(status_code=404, detail="Ticket not found") + + # Filter internal comments for non-admins + query = select(TicketComment).where(TicketComment.ticket_id == ticket.id) + is_admin = current_user and getattr(current_user, 'is_superuser', False) + if not is_admin: + query = query.where(TicketComment.is_internal == False) + + comments = session.exec(query.order_by(TicketComment.created_at)).all() + return comments + + +@router.post("/{ticket_number}/comments", response_model=TicketCommentRead) +def add_comment( + ticket_number: str, + comment: TicketCommentCreate, + session: Session = Depends(get_session), + current_user = Depends(get_current_user) if AUTH_AVAILABLE else None +): + """Add a comment to a ticket""" + if not current_user: + raise HTTPException(status_code=401, detail="Login required") + + ticket = session.exec( + select(Ticket).where(Ticket.ticket_number == ticket_number) + ).first() + + if not ticket: + raise HTTPException(status_code=404, detail="Ticket not found") + + # Check access + is_admin = getattr(current_user, 'is_superuser', False) + if ticket.reporter_user_id != current_user.id and not is_admin: + raise HTTPException(status_code=403, detail="Access denied") + + db_comment = TicketComment( + ticket_id=ticket.id, + author_user_id=current_user.id, + author_email=current_user.email, + author_name=current_user.email.split("@")[0], + content=comment.content, + is_internal=False + ) + + session.add(db_comment) + + # Update ticket timestamp + ticket.updated_at = datetime.utcnow() + session.add(ticket) + + session.commit() + session.refresh(db_comment) + + return db_comment + + +@router.post("/{ticket_number}/upvote") +def upvote_ticket( + ticket_number: str, + session: Session = Depends(get_session) +): + """Upvote a public ticket""" + ticket = session.exec( + select(Ticket).where(Ticket.ticket_number == ticket_number) + ).first() + + if not ticket: + raise HTTPException(status_code=404, detail="Ticket not found") + + if not ticket.is_public: + raise HTTPException(status_code=403, detail="Not a public ticket") + + ticket.upvotes += 1 + session.add(ticket) + session.commit() + + return {"upvotes": ticket.upvotes} + + +# --- Admin Endpoints --- + +@router.get("/admin/all", response_model=List[TicketRead]) +def admin_get_all_tickets( + session: Session = Depends(get_session), + current_user = Depends(get_current_user) if AUTH_AVAILABLE else None, + status: Optional[TicketStatus] = None, + type: Optional[TicketType] = None, + priority: Optional[TicketPriority] = None, + offset: int = 0, + limit: int = Query(default=50, le=100) +): + """Admin: Get all tickets with filters""" + if not current_user or not getattr(current_user, 'is_superuser', False): + raise HTTPException(status_code=403, detail="Admin access required") + + query = select(Ticket) + + if status: + query = query.where(Ticket.status == status) + if type: + query = query.where(Ticket.type == type) + if priority: + query = query.where(Ticket.priority == priority) + + tickets = session.exec( + query.order_by(Ticket.created_at.desc()) + .offset(offset) + .limit(limit) + ).all() + + return tickets + + +@router.patch("/admin/{ticket_number}", response_model=TicketRead) +def admin_update_ticket( + ticket_number: str, + update: TicketUpdate, + session: Session = Depends(get_session), + current_user = Depends(get_current_user) if AUTH_AVAILABLE else None +): + """Admin: Update ticket status, priority, assignment""" + if not current_user or not getattr(current_user, 'is_superuser', False): + raise HTTPException(status_code=403, detail="Admin access required") + + ticket = session.exec( + select(Ticket).where(Ticket.ticket_number == ticket_number) + ).first() + + if not ticket: + raise HTTPException(status_code=404, detail="Ticket not found") + + if update.status is not None: + ticket.status = update.status + if update.status == TicketStatus.RESOLVED: + ticket.resolved_at = datetime.utcnow() + + if update.priority is not None: + ticket.priority = update.priority + + if update.assigned_to_email is not None: + ticket.assigned_to_email = update.assigned_to_email + ticket.assigned_to_name = update.assigned_to_name + + if update.is_public is not None: + ticket.is_public = update.is_public + + ticket.updated_at = datetime.utcnow() + session.add(ticket) + session.commit() + session.refresh(ticket) + + return ticket + + +@router.post("/admin/{ticket_number}/internal-note", response_model=TicketCommentRead) +def admin_add_internal_note( + ticket_number: str, + comment: TicketCommentCreate, + session: Session = Depends(get_session), + current_user = Depends(get_current_user) if AUTH_AVAILABLE else None +): + """Admin: Add internal note (not visible to user)""" + if not current_user or not getattr(current_user, 'is_superuser', False): + raise HTTPException(status_code=403, detail="Admin access required") + + ticket = session.exec( + select(Ticket).where(Ticket.ticket_number == ticket_number) + ).first() + + if not ticket: + raise HTTPException(status_code=404, detail="Ticket not found") + + db_comment = TicketComment( + ticket_id=ticket.id, + author_user_id=current_user.id, + author_email=current_user.email, + author_name=current_user.email.split("@")[0], + content=comment.content, + is_internal=True + ) + + session.add(db_comment) + session.commit() + session.refresh(db_comment) + + return db_comment + + +@router.get("/admin/stats") +def admin_get_stats( + session: Session = Depends(get_session), + current_user = Depends(get_current_user) if AUTH_AVAILABLE else None +): + """Admin: Get ticket statistics""" + if not current_user or not getattr(current_user, 'is_superuser', False): + raise HTTPException(status_code=403, detail="Admin access required") + + total = session.exec(select(func.count(Ticket.id))).one() + open_count = session.exec( + select(func.count(Ticket.id)).where(Ticket.status == TicketStatus.OPEN) + ).one() + in_progress = session.exec( + select(func.count(Ticket.id)).where(Ticket.status == TicketStatus.IN_PROGRESS) + ).one() + resolved = session.exec( + select(func.count(Ticket.id)).where(Ticket.status == TicketStatus.RESOLVED) + ).one() + + return { + "total": total, + "open": open_count, + "in_progress": in_progress, + "resolved": resolved + } diff --git a/docs/BUGS_TRACKER_SPEC.md b/docs/BUGS_TRACKER_SPEC.md new file mode 100644 index 0000000..f7ecb6b --- /dev/null +++ b/docs/BUGS_TRACKER_SPEC.md @@ -0,0 +1,248 @@ +# Elmeg Bug Tracker Spec + +**Domain:** bugs.elmeg.xyz +**Style:** Zendesk-inspired support portal +**Date:** 2023-12-23 + +--- + +## Overview + +A lightweight, self-hosted bug/feedback tracker with a clean, user-first interface. Users can submit issues, track status, and browse known issues. Admins can triage, respond, and resolve. + +--- + +## User-Facing Features + +### 1. Submit a Report + +- **Type:** Bug, Feature Request, Question, Other +- **Title:** Short description (required) +- **Description:** Rich text with markdown support +- **Attachments:** Screenshot upload (optional) +- **Priority:** (auto or user-selectable) Low, Medium, High, Critical +- **Email:** For anonymous users (logged-in users auto-fill) +- **Environment:** Auto-capture: Browser, OS, page URL + +### 2. My Tickets + +- View submitted tickets +- Track status: Open → In Progress → Resolved → Closed +- Add comments/updates to existing tickets +- Receive email notifications on updates + +### 3. Knowledge Base / Known Issues + +- Browse open bugs (public visibility toggle per ticket) +- Search functionality +- "Me too" upvote button +- FAQ section + +### 4. Anonymous vs Authenticated + +- Logged-in Elmeg users: auto-linked, no email required +- Anonymous: requires email for updates + +--- + +## Admin Features + +### Dashboard + +- Ticket queue (filterable by status, type, priority, date) +- Unassigned tickets highlight +- Metrics: open count, avg response time, resolution rate + +### Ticket Management + +- Assign to self or team member +- Internal notes (not visible to user) +- Public reply +- Change status, priority, type +- Merge duplicate tickets +- Mark as known issue (public) + +### Canned Responses + +- Pre-written templates for common issues +- Variable substitution: `{user_name}`, `{ticket_id}` + +--- + +## Technical Architecture + +### Stack + +- **Frontend:** Next.js (standalone app, shares Elmeg styling) +- **Backend:** FastAPI (can share auth with main Elmeg backend) +- **Database:** PostgreSQL (new schema in same db or separate) +- **Storage:** S3/local for attachments + +### Models + +``` +Ticket +├── id (uuid) +├── ticket_number (auto-increment display ID: ELM-001) +├── type (bug | feature | question | other) +├── status (open | in_progress | resolved | closed) +├── priority (low | medium | high | critical) +├── title +├── description +├── reporter_email +├── reporter_user_id (nullable, FK to elmeg user) +├── assigned_to (nullable, FK to admin user) +├── is_public (for known issues) +├── upvotes +├── environment (JSON: browser, os, url) +├── created_at +├── updated_at +├── resolved_at + +TicketComment +├── id +├── ticket_id (FK) +├── author_id (FK) +├── author_email (for anonymous) +├── content +├── is_internal (admin-only visibility) +├── created_at + +TicketAttachment +├── id +├── ticket_id (FK) +├── filename +├── url +├── created_at + +CannedResponse +├── id +├── title +├── content +├── created_by +``` + +### API Endpoints + +**Public:** + +- `POST /tickets` - Create ticket +- `GET /tickets/{ticket_number}` - View ticket (if public or owned) +- `POST /tickets/{id}/comments` - Add comment +- `POST /tickets/{id}/upvote` - Upvote known issue +- `GET /known-issues` - List public tickets + +**Authenticated (Elmeg user):** + +- `GET /my-tickets` - User's tickets + +**Admin:** + +- `GET /admin/tickets` - All tickets (filtered) +- `PATCH /admin/tickets/{id}` - Update status, assign, etc. +- `POST /admin/tickets/{id}/internal-note` - Add internal note +- `GET /admin/canned-responses` +- `POST /admin/canned-responses` + +--- + +## UI Pages + +| Route | Purpose | +|-------|---------| +| `/` | Landing: "How can we help?" + Submit form | +| `/submit` | Full bug report form | +| `/my-tickets` | User's submitted tickets | +| `/ticket/[id]` | Ticket detail + comments | +| `/known-issues` | Public bug list with search | +| `/admin` | Admin dashboard | +| `/admin/ticket/[id]` | Admin ticket view | + +--- + +## Email Notifications + +| Event | Recipient | +|-------|-----------| +| Ticket created | User (confirmation) | +| Admin replies | User | +| Status changed | User | +| User comment | Assigned admin | + +Uses existing AWS SES integration. + +--- + +## Deployment + +### Docker Compose (separate service) + +```yaml +services: + bugs-frontend: + build: ./bugs-frontend + labels: + - "traefik.http.routers.bugs.rule=Host(`bugs.elmeg.xyz`)" + + bugs-backend: + build: ./bugs-backend + environment: + - DATABASE_URL=postgresql://... + - ELMEG_API_URL=https://elmeg.xyz/api +``` + +### DNS + +Add A record or CNAME for `bugs.elmeg.xyz` → production server + +### Auth Integration + +- Share JWT validation with main Elmeg backend +- Or standalone with optional Elmeg SSO + +--- + +## MVP Scope (Phase 1) + +- [ ] Submit ticket form +- [ ] My tickets list +- [ ] Ticket detail with comments +- [ ] Admin ticket queue +- [ ] Email on ticket creation +- [ ] Basic styling matching Elmeg + +## Phase 2 + +- [ ] Known issues page +- [ ] Upvoting +- [ ] Canned responses +- [ ] Internal notes +- [ ] Attachment uploads +- [ ] Search + +## Phase 3 + +- [ ] Metrics dashboard +- [ ] Merge tickets +- [ ] Tags/labels +- [ ] Webhook integrations (Discord, Slack) + +--- + +## Estimated Effort + +| Phase | Effort | +|-------|--------| +| MVP | 2-3 days | +| Phase 2 | 2 days | +| Phase 3 | 2 days | + +--- + +## Next Steps + +1. Create `/srv/containers/elmeg-bugs` directory structure +2. Initialize Next.js + FastAPI project +3. Set up Traefik routing for `bugs.elmeg.xyz` +4. Implement MVP models + endpoints +5. Deploy diff --git a/frontend/app/bugs/known-issues/page.tsx b/frontend/app/bugs/known-issues/page.tsx new file mode 100644 index 0000000..6860863 --- /dev/null +++ b/frontend/app/bugs/known-issues/page.tsx @@ -0,0 +1,143 @@ +"use client" + +/** + * Known Issues Page - Public bugs/feature requests + */ + +import { useEffect, useState } from "react" +import { Card, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { getApiUrl } from "@/lib/api-config" +import Link from "next/link" +import { ArrowLeft, ThumbsUp, Bug, Lightbulb, Loader2 } from "lucide-react" + +interface Ticket { + id: number + ticket_number: string + type: string + status: string + priority: string + title: string + description: string + upvotes: number + created_at: string +} + +const TYPE_ICONS: Record = { + bug: Bug, + feature: Lightbulb, +} + +export default function KnownIssuesPage() { + const [tickets, setTickets] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetchKnownIssues() + }, []) + + const fetchKnownIssues = async () => { + try { + const res = await fetch(`${getApiUrl()}/tickets/known-issues`) + if (res.ok) { + setTickets(await res.json()) + } + } catch (e) { + console.error("Failed to fetch known issues", e) + } finally { + setLoading(false) + } + } + + const handleUpvote = async (ticketNumber: string, e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + try { + await fetch(`${getApiUrl()}/tickets/${ticketNumber}/upvote`, { + method: "POST", + }) + // Optimistic update + setTickets(tickets.map(t => + t.ticket_number === ticketNumber + ? { ...t, upvotes: t.upvotes + 1 } + : t + )) + } catch (e) { + console.error("Failed to upvote", e) + } + } + + return ( +
+
+ + + +
+

Known Issues

+

Active bugs and feature requests

+
+
+ + {loading ? ( +
+ +
+ ) : tickets.length === 0 ? ( + + +

No known issues at this time.

+
+
+ ) : ( +
+ {tickets.map((ticket) => { + const Icon = TYPE_ICONS[ticket.type] || Bug + return ( + + + +
+
+ +
+
+
+ + {ticket.ticket_number} + + + {ticket.status === "in_progress" ? "In Progress" : "Open"} + +
+

{ticket.title}

+ {ticket.description && ( +

+ {ticket.description} +

+ )} +
+ +
+
+
+ + ) + })} +
+ )} +
+ ) +} diff --git a/frontend/app/bugs/my-tickets/page.tsx b/frontend/app/bugs/my-tickets/page.tsx new file mode 100644 index 0000000..dc8a476 --- /dev/null +++ b/frontend/app/bugs/my-tickets/page.tsx @@ -0,0 +1,146 @@ +"use client" + +/** + * My Tickets Page - View user's submitted tickets + */ + +import { useEffect, useState } from "react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { useAuth } from "@/contexts/auth-context" +import { getApiUrl } from "@/lib/api-config" +import Link from "next/link" +import { ArrowLeft, Clock, CheckCircle, AlertCircle, Loader2 } from "lucide-react" + +interface Ticket { + id: number + ticket_number: string + type: string + status: string + priority: string + title: string + created_at: string + updated_at: string +} + +const STATUS_STYLES: Record = { + open: { label: "Open", variant: "default" }, + in_progress: { label: "In Progress", variant: "secondary" }, + resolved: { label: "Resolved", variant: "outline" }, + closed: { label: "Closed", variant: "outline" }, +} + +const PRIORITY_COLORS: Record = { + low: "bg-gray-500", + medium: "bg-yellow-500", + high: "bg-orange-500", + critical: "bg-red-500", +} + +export default function MyTicketsPage() { + const { user } = useAuth() + const [tickets, setTickets] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (user) { + fetchTickets() + } + }, [user]) + + const fetchTickets = async () => { + try { + const token = localStorage.getItem("token") + const res = await fetch(`${getApiUrl()}/tickets/my-tickets`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (res.ok) { + const data = await res.json() + setTickets(data) + } + } catch (e) { + console.error("Failed to fetch tickets", e) + } finally { + setLoading(false) + } + } + + if (!user) { + return ( +
+

Please Log In

+

+ You need to be logged in to view your tickets. +

+ + + +
+ ) + } + + return ( +
+
+ + + +
+

My Tickets

+

Track your submitted issues

+
+
+ + {loading ? ( +
+ +
+ ) : tickets.length === 0 ? ( + + +

You haven't submitted any tickets yet.

+ + + +
+
+ ) : ( +
+ {tickets.map((ticket) => ( + + + +
+
+
+ + {ticket.ticket_number} + + + + {STATUS_STYLES[ticket.status]?.label || ticket.status} + +
+

{ticket.title}

+

+ {new Date(ticket.created_at).toLocaleDateString()} +

+
+
+ {ticket.status === "open" && } + {ticket.status === "resolved" && } + {ticket.status === "in_progress" && } +
+
+
+
+ + ))} +
+ )} +
+ ) +} diff --git a/frontend/app/bugs/page.tsx b/frontend/app/bugs/page.tsx new file mode 100644 index 0000000..95f95d0 --- /dev/null +++ b/frontend/app/bugs/page.tsx @@ -0,0 +1,299 @@ +"use client" + +/** + * Bug Tracker - Main Page + * Submit bugs, feature requests, and questions + */ + +import { useState } from "react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Label } from "@/components/ui/label" +import { + Bug, + Lightbulb, + HelpCircle, + MessageSquare, + CheckCircle, + ArrowRight, + ExternalLink +} from "lucide-react" +import Link from "next/link" +import { getApiUrl } from "@/lib/api-config" +import { useAuth } from "@/contexts/auth-context" + +const TICKET_TYPES = [ + { value: "bug", label: "Bug Report", icon: Bug, description: "Something isn't working" }, + { value: "feature", label: "Feature Request", icon: Lightbulb, description: "Suggest an improvement" }, + { value: "question", label: "Question", icon: HelpCircle, description: "Ask for help" }, + { value: "other", label: "Other", icon: MessageSquare, description: "General feedback" }, +] + +const PRIORITIES = [ + { value: "low", label: "Low", color: "bg-gray-500" }, + { value: "medium", label: "Medium", color: "bg-yellow-500" }, + { value: "high", label: "High", color: "bg-orange-500" }, + { value: "critical", label: "Critical", color: "bg-red-500" }, +] + +export default function BugsPage() { + const { user } = useAuth() + const [step, setStep] = useState<"type" | "form" | "success">("type") + const [selectedType, setSelectedType] = useState("bug") + const [submitting, setSubmitting] = useState(false) + const [ticketNumber, setTicketNumber] = useState("") + const [error, setError] = useState("") + + const [formData, setFormData] = useState({ + title: "", + description: "", + priority: "medium", + reporter_email: "", + reporter_name: "", + }) + + const handleTypeSelect = (type: string) => { + setSelectedType(type) + setStep("form") + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setSubmitting(true) + setError("") + + try { + const token = localStorage.getItem("token") + const headers: Record = { + "Content-Type": "application/json", + } + if (token) { + headers["Authorization"] = `Bearer ${token}` + } + + const res = await fetch(`${getApiUrl()}/tickets/`, { + method: "POST", + headers, + body: JSON.stringify({ + type: selectedType, + priority: formData.priority, + title: formData.title, + description: formData.description, + reporter_email: user?.email || formData.reporter_email, + reporter_name: formData.reporter_name, + page_url: window.location.href, + browser: navigator.userAgent, + }), + }) + + if (!res.ok) { + const data = await res.json() + throw new Error(data.detail || "Failed to submit") + } + + const ticket = await res.json() + setTicketNumber(ticket.ticket_number) + setStep("success") + } catch (e: any) { + setError(e.message || "Failed to submit ticket") + } finally { + setSubmitting(false) + } + } + + // Success screen + if (step === "success") { + return ( +
+ + +
+ +
+
+

Ticket Submitted!

+

+ Your ticket number is {ticketNumber} +

+
+

+ We'll review your submission and get back to you soon. +

+
+ + + + +
+
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+

How can we help?

+

+ Report bugs, request features, or ask questions +

+
+ + {step === "type" && ( + <> + {/* Type Selection */} +
+ {TICKET_TYPES.map((type) => { + const Icon = type.icon + return ( + + ) + })} +
+ + {/* Quick Links */} +
+ + + + {user && ( + + + + )} +
+ + )} + + {step === "form" && ( + + +
+ +
+ + {TICKET_TYPES.find(t => t.value === selectedType)?.icon && ( + (() => { + const Icon = TICKET_TYPES.find(t => t.value === selectedType)!.icon + return + })() + )} + {TICKET_TYPES.find(t => t.value === selectedType)?.label} + + + Fill out the details below + +
+ +
+ {/* Title */} +
+ + setFormData({ ...formData, title: e.target.value })} + required + maxLength={200} + /> +
+ + {/* Description */} +
+ +