feat: Add bug tracker MVP (decoupled, feature-flagged)
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run

- 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/
This commit is contained in:
fullsizemalt 2025-12-23 13:18:00 -08:00
parent 2da46eaa16
commit 14a509ddb5
8 changed files with 1597 additions and 0 deletions

View file

@ -1,8 +1,12 @@
from fastapi import FastAPI 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 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 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 = FastAPI()
app.add_middleware( app.add_middleware(
@ -39,6 +43,11 @@ app.include_router(chase.router)
app.include_router(gamification.router) app.include_router(gamification.router)
app.include_router(videos.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("/") @app.get("/")
def read_root(): def read_root():
return {"Hello": "World"} return {"Hello": "World"}

140
backend/models_tickets.py Normal file
View file

@ -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

371
backend/routers/tickets.py Normal file
View file

@ -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
}

248
docs/BUGS_TRACKER_SPEC.md Normal file
View file

@ -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

View file

@ -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<string, typeof Bug> = {
bug: Bug,
feature: Lightbulb,
}
export default function KnownIssuesPage() {
const [tickets, setTickets] = useState<Ticket[]>([])
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 (
<div className="container max-w-4xl py-8">
<div className="flex items-center gap-4 mb-8">
<Link href="/bugs">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold">Known Issues</h1>
<p className="text-muted-foreground">Active bugs and feature requests</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : tickets.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">No known issues at this time.</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{tickets.map((ticket) => {
const Icon = TYPE_ICONS[ticket.type] || Bug
return (
<Link key={ticket.id} href={`/bugs/ticket/${ticket.ticket_number}`}>
<Card className="hover:border-primary transition-colors cursor-pointer">
<CardContent className="py-4">
<div className="flex items-start gap-4">
<div className="p-2 rounded-lg bg-muted">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-sm text-muted-foreground">
{ticket.ticket_number}
</span>
<Badge variant={ticket.status === "in_progress" ? "secondary" : "default"}>
{ticket.status === "in_progress" ? "In Progress" : "Open"}
</Badge>
</div>
<h3 className="font-semibold">{ticket.title}</h3>
{ticket.description && (
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
{ticket.description}
</p>
)}
</div>
<Button
variant="outline"
size="sm"
className="gap-2 shrink-0"
onClick={(e) => handleUpvote(ticket.ticket_number, e)}
>
<ThumbsUp className="h-4 w-4" />
{ticket.upvotes}
</Button>
</div>
</CardContent>
</Card>
</Link>
)
})}
</div>
)}
</div>
)
}

View file

@ -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<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
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<string, string> = {
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<Ticket[]>([])
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 (
<div className="container max-w-2xl py-16 text-center">
<h1 className="text-2xl font-bold mb-4">Please Log In</h1>
<p className="text-muted-foreground mb-6">
You need to be logged in to view your tickets.
</p>
<Link href="/login">
<Button>Log In</Button>
</Link>
</div>
)
}
return (
<div className="container max-w-4xl py-8">
<div className="flex items-center gap-4 mb-8">
<Link href="/bugs">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold">My Tickets</h1>
<p className="text-muted-foreground">Track your submitted issues</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : tickets.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground mb-4">You haven't submitted any tickets yet.</p>
<Link href="/bugs">
<Button>Submit a Ticket</Button>
</Link>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{tickets.map((ticket) => (
<Link key={ticket.id} href={`/bugs/ticket/${ticket.ticket_number}`}>
<Card className="hover:border-primary transition-colors cursor-pointer">
<CardContent className="py-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-sm text-muted-foreground">
{ticket.ticket_number}
</span>
<span className={`inline-block w-2 h-2 rounded-full ${PRIORITY_COLORS[ticket.priority]}`} />
<Badge variant={STATUS_STYLES[ticket.status]?.variant || "default"}>
{STATUS_STYLES[ticket.status]?.label || ticket.status}
</Badge>
</div>
<h3 className="font-semibold truncate">{ticket.title}</h3>
<p className="text-sm text-muted-foreground">
{new Date(ticket.created_at).toLocaleDateString()}
</p>
</div>
<div className="flex items-center">
{ticket.status === "open" && <Clock className="h-4 w-4 text-muted-foreground" />}
{ticket.status === "resolved" && <CheckCircle className="h-4 w-4 text-green-500" />}
{ticket.status === "in_progress" && <AlertCircle className="h-4 w-4 text-yellow-500" />}
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
)
}

299
frontend/app/bugs/page.tsx Normal file
View file

@ -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<string>("bug")
const [submitting, setSubmitting] = useState(false)
const [ticketNumber, setTicketNumber] = useState<string>("")
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<string, string> = {
"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 (
<div className="container max-w-2xl py-16">
<Card className="text-center">
<CardContent className="pt-12 pb-8 space-y-6">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 dark:bg-green-900">
<CheckCircle className="h-8 w-8 text-green-600 dark:text-green-400" />
</div>
<div>
<h1 className="text-2xl font-bold">Ticket Submitted!</h1>
<p className="text-muted-foreground mt-2">
Your ticket number is <span className="font-mono font-bold">{ticketNumber}</span>
</p>
</div>
<p className="text-sm text-muted-foreground">
We'll review your submission and get back to you soon.
</p>
<div className="flex gap-4 justify-center pt-4">
<Link href="/bugs/my-tickets">
<Button variant="outline">View My Tickets</Button>
</Link>
<Button onClick={() => { setStep("type"); setFormData({ title: "", description: "", priority: "medium", reporter_email: "", reporter_name: "" }) }}>
Submit Another
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="container max-w-4xl py-8">
{/* Header */}
<div className="text-center mb-12">
<h1 className="text-4xl font-bold tracking-tight mb-4">How can we help?</h1>
<p className="text-muted-foreground text-lg">
Report bugs, request features, or ask questions
</p>
</div>
{step === "type" && (
<>
{/* Type Selection */}
<div className="grid md:grid-cols-2 gap-4 mb-8">
{TICKET_TYPES.map((type) => {
const Icon = type.icon
return (
<button
key={type.value}
onClick={() => handleTypeSelect(type.value)}
className="text-left p-6 rounded-xl border-2 border-border hover:border-primary transition-colors bg-card hover:bg-accent group"
>
<div className="flex items-start gap-4">
<div className="p-3 rounded-lg bg-primary/10 text-primary group-hover:bg-primary group-hover:text-primary-foreground transition-colors">
<Icon className="h-6 w-6" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-lg">{type.label}</h3>
<p className="text-muted-foreground text-sm">{type.description}</p>
</div>
<ArrowRight className="h-5 w-5 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
</button>
)
})}
</div>
{/* Quick Links */}
<div className="flex flex-wrap gap-4 justify-center">
<Link href="/bugs/known-issues">
<Button variant="outline" className="gap-2">
<ExternalLink className="h-4 w-4" />
Known Issues
</Button>
</Link>
{user && (
<Link href="/bugs/my-tickets">
<Button variant="outline" className="gap-2">
My Tickets
</Button>
</Link>
)}
</div>
</>
)}
{step === "form" && (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setStep("type")}>
Back
</Button>
</div>
<CardTitle className="flex items-center gap-2">
{TICKET_TYPES.find(t => t.value === selectedType)?.icon && (
(() => {
const Icon = TICKET_TYPES.find(t => t.value === selectedType)!.icon
return <Icon className="h-5 w-5" />
})()
)}
{TICKET_TYPES.find(t => t.value === selectedType)?.label}
</CardTitle>
<CardDescription>
Fill out the details below
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Title */}
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
placeholder="Brief summary of the issue"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
maxLength={200}
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Provide more details. What happened? What did you expect?"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={5}
/>
</div>
{/* Priority */}
<div className="space-y-2">
<Label>Priority</Label>
<div className="flex gap-2">
{PRIORITIES.map((p) => (
<button
key={p.value}
type="button"
onClick={() => setFormData({ ...formData, priority: p.value })}
className={`px-4 py-2 rounded-lg border-2 transition-colors ${formData.priority === p.value
? "border-primary bg-primary/10"
: "border-border hover:border-muted-foreground"
}`}
>
<span className={`inline-block w-2 h-2 rounded-full ${p.color} mr-2`} />
{p.label}
</button>
))}
</div>
</div>
{/* Email (for anonymous) */}
{!user && (
<div className="space-y-2">
<Label htmlFor="email">Your Email *</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={formData.reporter_email}
onChange={(e) => setFormData({ ...formData, reporter_email: e.target.value })}
required
/>
<p className="text-xs text-muted-foreground">
We'll use this to send you updates
</p>
</div>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<div className="flex gap-4 pt-4">
<Button type="button" variant="outline" onClick={() => setStep("type")}>
Cancel
</Button>
<Button type="submit" disabled={submitting || !formData.title}>
{submitting ? "Submitting..." : "Submit Ticket"}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
</div>
)
}

View file

@ -0,0 +1,241 @@
"use client"
/**
* Ticket Detail Page
*/
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Textarea } from "@/components/ui/textarea"
import { useAuth } from "@/contexts/auth-context"
import { getApiUrl } from "@/lib/api-config"
import Link from "next/link"
import { ArrowLeft, Send, Loader2, Clock, User } from "lucide-react"
interface Ticket {
id: number
ticket_number: string
type: string
status: string
priority: string
title: string
description: string
reporter_email: string
reporter_name: string
is_public: boolean
upvotes: number
created_at: string
updated_at: string
resolved_at: string | null
}
interface Comment {
id: number
author_name: string
content: string
is_internal: boolean
created_at: string
}
const STATUS_STYLES: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
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<string, string> = {
low: "bg-gray-500",
medium: "bg-yellow-500",
high: "bg-orange-500",
critical: "bg-red-500",
}
export default function TicketDetailPage() {
const params = useParams()
const ticketNumber = params.id as string
const { user } = useAuth()
const [ticket, setTicket] = useState<Ticket | null>(null)
const [comments, setComments] = useState<Comment[]>([])
const [loading, setLoading] = useState(true)
const [newComment, setNewComment] = useState("")
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState("")
useEffect(() => {
if (ticketNumber) {
fetchTicket()
fetchComments()
}
}, [ticketNumber])
const fetchTicket = async () => {
try {
const token = localStorage.getItem("token")
const headers: Record<string, string> = {}
if (token) headers["Authorization"] = `Bearer ${token}`
const res = await fetch(`${getApiUrl()}/tickets/${ticketNumber}`, { headers })
if (res.ok) {
setTicket(await res.json())
} else if (res.status === 404) {
setError("Ticket not found")
} else if (res.status === 403) {
setError("You don't have access to this ticket")
}
} catch (e) {
setError("Failed to load ticket")
} finally {
setLoading(false)
}
}
const fetchComments = async () => {
try {
const token = localStorage.getItem("token")
const headers: Record<string, string> = {}
if (token) headers["Authorization"] = `Bearer ${token}`
const res = await fetch(`${getApiUrl()}/tickets/${ticketNumber}/comments`, { headers })
if (res.ok) {
setComments(await res.json())
}
} catch (e) {
console.error("Failed to load comments", e)
}
}
const handleSubmitComment = async (e: React.FormEvent) => {
e.preventDefault()
if (!newComment.trim()) return
setSubmitting(true)
try {
const token = localStorage.getItem("token")
const res = await fetch(`${getApiUrl()}/tickets/${ticketNumber}/comments`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ content: newComment }),
})
if (res.ok) {
const comment = await res.json()
setComments([...comments, comment])
setNewComment("")
}
} catch (e) {
console.error("Failed to add comment", e)
} finally {
setSubmitting(false)
}
}
if (loading) {
return (
<div className="container max-w-3xl py-16 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)
}
if (error || !ticket) {
return (
<div className="container max-w-3xl py-16 text-center">
<h1 className="text-2xl font-bold mb-4">{error || "Ticket not found"}</h1>
<Link href="/bugs">
<Button>Back to Bug Tracker</Button>
</Link>
</div>
)
}
return (
<div className="container max-w-3xl py-8">
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<Link href="/bugs/my-tickets">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-muted-foreground">{ticket.ticket_number}</span>
<span className={`inline-block w-2 h-2 rounded-full ${PRIORITY_COLORS[ticket.priority]}`} />
<Badge variant={STATUS_STYLES[ticket.status]?.variant || "default"}>
{STATUS_STYLES[ticket.status]?.label || ticket.status}
</Badge>
</div>
</div>
</div>
{/* Ticket Content */}
<Card className="mb-6">
<CardHeader>
<CardTitle>{ticket.title}</CardTitle>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<User className="h-3 w-3" />
{ticket.reporter_name || "Anonymous"}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(ticket.created_at).toLocaleString()}
</span>
</div>
</CardHeader>
{ticket.description && (
<CardContent>
<p className="whitespace-pre-wrap">{ticket.description}</p>
</CardContent>
)}
</Card>
{/* Comments */}
<div className="space-y-4 mb-6">
<h3 className="font-semibold">Comments ({comments.length})</h3>
{comments.length === 0 ? (
<p className="text-sm text-muted-foreground">No comments yet</p>
) : (
comments.map((comment) => (
<Card key={comment.id}>
<CardContent className="py-4">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium">{comment.author_name}</span>
<span className="text-sm text-muted-foreground">
{new Date(comment.created_at).toLocaleString()}
</span>
</div>
<p className="whitespace-pre-wrap">{comment.content}</p>
</CardContent>
</Card>
))
)}
</div>
{/* Add Comment Form */}
{user && (
<form onSubmit={handleSubmitComment} className="space-y-4">
<Textarea
placeholder="Add a comment..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
rows={3}
/>
<Button type="submit" disabled={submitting || !newComment.trim()} className="gap-2">
<Send className="h-4 w-4" />
{submitting ? "Sending..." : "Send Comment"}
</Button>
</form>
)}
</div>
)
}