feat: add category field to tickets system

This commit is contained in:
Tiago Yamamoto 2026-02-23 15:43:35 -06:00
parent 3583ef89d8
commit 74afffa4a9
9 changed files with 67 additions and 37 deletions

View file

@ -801,7 +801,7 @@ func (h *CoreHandlers) CreateTicket(w http.ResponseWriter, r *http.Request) {
return
}
ticket, err := h.ticketService.CreateTicket(r.Context(), userID, req.Subject, req.Priority)
ticket, err := h.ticketService.CreateTicket(r.Context(), userID, req.Subject, req.Category, req.Priority)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return

View file

@ -4,6 +4,7 @@ import "github.com/rede5/gohorsejobs/backend/internal/models"
type CreateTicketRequest struct {
Subject string `json:"subject"`
Category string `json:"category"`
Priority string `json:"priority"`
Message string `json:"message"` // Initial message
}

View file

@ -35,7 +35,7 @@ func (h *TicketHandler) CreateTicket(w http.ResponseWriter, r *http.Request) {
return
}
ticket, err := h.service.CreateTicket(r.Context(), userID, req.Subject, req.Priority)
ticket, err := h.service.CreateTicket(r.Context(), userID, req.Subject, req.Category, req.Priority)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return

View file

@ -6,6 +6,7 @@ type Ticket struct {
ID string `json:"id"`
UserID string `json:"userId"`
Subject string `json:"subject"`
Category string `json:"category"` // bug, feature, support, billing, other
Status string `json:"status"` // open, in_progress, closed
Priority string `json:"priority"` // low, medium, high
CreatedAt time.Time `json:"createdAt"`
@ -22,6 +23,7 @@ type TicketMessage struct {
type CreateTicketRequest struct {
Subject string `json:"subject"`
Category string `json:"category,omitempty"`
Priority string `json:"priority,omitempty"`
}

View file

@ -16,18 +16,21 @@ func NewTicketService(db *sql.DB) *TicketService {
return &TicketService{DB: db}
}
func (s *TicketService) CreateTicket(ctx context.Context, userID string, subject, priority string) (*models.Ticket, error) {
func (s *TicketService) CreateTicket(ctx context.Context, userID string, subject, category, priority string) (*models.Ticket, error) {
if priority == "" {
priority = "medium"
}
if category == "" {
category = "other"
}
query := `
INSERT INTO tickets (user_id, subject, status, priority, created_at, updated_at)
VALUES ($1, $2, 'open', $3, NOW(), NOW())
RETURNING id, user_id, subject, status, priority, created_at, updated_at
INSERT INTO tickets (user_id, subject, category, status, priority, created_at, updated_at)
VALUES ($1, $2, $3, 'open', $4, NOW(), NOW())
RETURNING id, user_id, subject, category, status, priority, created_at, updated_at
`
var t models.Ticket
err := s.DB.QueryRowContext(ctx, query, userID, subject, priority).Scan(
&t.ID, &t.UserID, &t.Subject, &t.Status, &t.Priority, &t.CreatedAt, &t.UpdatedAt,
err := s.DB.QueryRowContext(ctx, query, userID, subject, category, priority).Scan(
&t.ID, &t.UserID, &t.Subject, &t.Category, &t.Status, &t.Priority, &t.CreatedAt, &t.UpdatedAt,
)
if err != nil {
return nil, err
@ -37,7 +40,7 @@ func (s *TicketService) CreateTicket(ctx context.Context, userID string, subject
func (s *TicketService) ListTickets(ctx context.Context, userID string) ([]models.Ticket, error) {
query := `
SELECT id, user_id, subject, status, priority, created_at, updated_at
SELECT id, user_id, subject, category, status, priority, created_at, updated_at
FROM tickets
WHERE user_id = $1
ORDER BY updated_at DESC
@ -52,7 +55,7 @@ func (s *TicketService) ListTickets(ctx context.Context, userID string) ([]model
for rows.Next() {
var t models.Ticket
if err := rows.Scan(
&t.ID, &t.UserID, &t.Subject, &t.Status, &t.Priority, &t.CreatedAt, &t.UpdatedAt,
&t.ID, &t.UserID, &t.Subject, &t.Category, &t.Status, &t.Priority, &t.CreatedAt, &t.UpdatedAt,
); err != nil {
return nil, err
}
@ -64,7 +67,7 @@ func (s *TicketService) ListTickets(ctx context.Context, userID string) ([]model
func (s *TicketService) GetTicket(ctx context.Context, ticketID string, userID string, isAdmin bool) (*models.Ticket, []models.TicketMessage, error) {
// 1. Get Ticket
queryTicket := `
SELECT id, user_id, subject, status, priority, created_at, updated_at
SELECT id, user_id, subject, category, status, priority, created_at, updated_at
FROM tickets
WHERE id = $1
`
@ -76,7 +79,7 @@ func (s *TicketService) GetTicket(ctx context.Context, ticketID string, userID s
var t models.Ticket
err := s.DB.QueryRowContext(ctx, queryTicket, args...).Scan(
&t.ID, &t.UserID, &t.Subject, &t.Status, &t.Priority, &t.CreatedAt, &t.UpdatedAt,
&t.ID, &t.UserID, &t.Subject, &t.Category, &t.Status, &t.Priority, &t.CreatedAt, &t.UpdatedAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@ -181,11 +184,11 @@ func (s *TicketService) UpdateTicket(ctx context.Context, ticketID string, userI
}
args = append(args, ticketID)
query := "UPDATE tickets SET " + joinStrings(setClauses, ", ") + " WHERE id = $" + string(rune('0'+argIdx)) + " RETURNING id, user_id, subject, status, priority, created_at, updated_at"
query := "UPDATE tickets SET " + joinStrings(setClauses, ", ") + " WHERE id = $" + string(rune('0'+argIdx)) + " RETURNING id, user_id, subject, category, status, priority, created_at, updated_at"
var t models.Ticket
err = s.DB.QueryRowContext(ctx, query, args...).Scan(
&t.ID, &t.UserID, &t.Subject, &t.Status, &t.Priority, &t.CreatedAt, &t.UpdatedAt,
&t.ID, &t.UserID, &t.Subject, &t.Category, &t.Status, &t.Priority, &t.CreatedAt, &t.UpdatedAt,
)
if err != nil {
return nil, err
@ -224,7 +227,7 @@ func (s *TicketService) DeleteTicket(ctx context.Context, ticketID string) error
// ListAllTickets returns all tickets (for admin)
func (s *TicketService) ListAllTickets(ctx context.Context, status string) ([]models.Ticket, error) {
query := `
SELECT id, user_id, subject, status, priority, created_at, updated_at
SELECT id, user_id, subject, category, status, priority, created_at, updated_at
FROM tickets
`
args := []interface{}{}
@ -246,7 +249,7 @@ func (s *TicketService) ListAllTickets(ctx context.Context, status string) ([]mo
for rows.Next() {
var t models.Ticket
if err := rows.Scan(
&t.ID, &t.UserID, &t.Subject, &t.Status, &t.Priority, &t.CreatedAt, &t.UpdatedAt,
&t.ID, &t.UserID, &t.Subject, &t.Category, &t.Status, &t.Priority, &t.CreatedAt, &t.UpdatedAt,
); err != nil {
return nil, err
}

View file

@ -21,19 +21,19 @@ func TestTicketService_CRUD(t *testing.T) {
// 1. Create Ticket
mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO tickets`)).
WithArgs("user-id", "Help", "high").
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "status", "priority", "created_at", "updated_at"}).
AddRow("ticket-id", "user-id", "Help", "open", "high", time.Now(), time.Now()))
WithArgs("user-id", "Help", "support", "high").
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "category", "status", "priority", "created_at", "updated_at"}).
AddRow("ticket-id", "user-id", "Help", "support", "open", "high", time.Now(), time.Now()))
ticket, err := service.CreateTicket(ctx, "user-id", "Help", "high")
ticket, err := service.CreateTicket(ctx, "user-id", "Help", "support", "high")
assert.NoError(t, err)
assert.Equal(t, "ticket-id", ticket.ID)
// 2. List Tickets
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, user_id, subject, status, priority, created_at, updated_at FROM tickets`)).
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, user_id, subject, category, status, priority, created_at, updated_at FROM tickets`)).
WithArgs("user-id").
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "status", "priority", "created_at", "updated_at"}).
AddRow("ticket-id", "user-id", "Help", "open", "high", time.Now(), time.Now()))
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "category", "status", "priority", "created_at", "updated_at"}).
AddRow("ticket-id", "user-id", "Help", "support", "open", "high", time.Now(), time.Now()))
tickets, err := service.ListTickets(ctx, "user-id")
assert.NoError(t, err)
@ -63,8 +63,8 @@ func TestTicketService_CRUD(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow("user-id"))
mock.ExpectQuery(regexp.QuoteMeta(`UPDATE tickets SET updated_at = NOW(), status = $1 WHERE id = $2`)).
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "status", "priority", "created_at", "updated_at"}).
AddRow("ticket-id", "user-id", "Help", "closed", "high", time.Now(), time.Now()))
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "category", "status", "priority", "created_at", "updated_at"}).
AddRow("ticket-id", "user-id", "Help", "support", "closed", "high", time.Now(), time.Now()))
_, err = service.CloseTicket(ctx, "ticket-id", "user-id", false)
assert.NoError(t, err)
@ -79,10 +79,10 @@ func TestTicketService_Extended(t *testing.T) {
ctx := context.Background()
// 1. GetTicket (With messages)
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, user_id, subject, status, priority, created_at, updated_at FROM tickets WHERE id = $1 AND user_id = $2`)).
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, user_id, subject, category, status, priority, created_at, updated_at FROM tickets WHERE id = $1 AND user_id = $2`)).
WithArgs("ticket-id", "user-id").
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "status", "priority", "created_at", "updated_at"}).
AddRow("ticket-id", "user-id", "Subject", "open", "high", time.Now(), time.Now()))
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "category", "status", "priority", "created_at", "updated_at"}).
AddRow("ticket-id", "user-id", "Subject", "support", "open", "high", time.Now(), time.Now()))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, ticket_id, user_id, message, created_at FROM ticket_messages WHERE ticket_id = $1 ORDER BY created_at ASC`)).
WithArgs("ticket-id").
@ -107,10 +107,10 @@ func TestTicketService_Extended(t *testing.T) {
assert.NoError(t, err)
// 3. ListAllTickets
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, user_id, subject, status, priority, created_at, updated_at FROM tickets ORDER BY updated_at DESC`)).
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "status", "priority", "created_at", "updated_at"}).
AddRow("ticket-1", "u1", "s1", "open", "low", time.Now(), time.Now()).
AddRow("ticket-2", "u2", "s2", "closed", "high", time.Now(), time.Now()))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, user_id, subject, category, status, priority, created_at, updated_at FROM tickets ORDER BY updated_at DESC`)).
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "category", "status", "priority", "created_at", "updated_at"}).
AddRow("ticket-1", "u1", "s1", "support", "open", "low", time.Now(), time.Now()).
AddRow("ticket-2", "u2", "s2", "bug", "closed", "high", time.Now(), time.Now()))
allTickets, err := service.ListAllTickets(ctx, "")
assert.NoError(t, err)

View file

@ -0,0 +1,5 @@
-- Add category column to tickets table
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS category VARCHAR(50) DEFAULT 'other';
-- Update existing tickets to have default category
UPDATE tickets SET category = 'other' WHERE category IS NULL;

View file

@ -27,7 +27,7 @@ export default function TicketsPage() {
const [tickets, setTickets] = useState<Ticket[]>([])
const [loading, setLoading] = useState(true)
const [isCreateOpen, setIsCreateOpen] = useState(false)
const [newTicket, setNewTicket] = useState({ subject: "", priority: "medium", message: "" })
const [newTicket, setNewTicket] = useState({ subject: "", category: "support", priority: "medium", message: "" })
const fetchTickets = async () => {
try {
@ -52,7 +52,7 @@ export default function TicketsPage() {
await ticketsApi.create(newTicket)
toast.success("Ticket created successfully")
setIsCreateOpen(false)
setNewTicket({ subject: "", priority: "medium", message: "" })
setNewTicket({ subject: "", category: "support", priority: "medium", message: "" })
fetchTickets() // Refresh
} catch (error) {
console.error("Failed to create ticket", error)
@ -107,6 +107,24 @@ export default function TicketsPage() {
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="category" className="text-right">Category</Label>
<Select
value={newTicket.category}
onValueChange={(val) => setNewTicket({ ...newTicket, category: val })}
>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="bug">Bug</SelectItem>
<SelectItem value="feature">Feature Request</SelectItem>
<SelectItem value="support">Support</SelectItem>
<SelectItem value="billing">Billing</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="priority" className="text-right">Priority</Label>
<Select

View file

@ -683,8 +683,9 @@ export const notificationsApi = {
// --- Support Tickets ---
export interface Ticket {
id: string;
userId: number;
userId: string;
subject: string;
category: 'bug' | 'feature' | 'support' | 'billing' | 'other';
status: 'open' | 'in_progress' | 'closed';
priority: 'low' | 'medium' | 'high';
createdAt: string;
@ -694,13 +695,13 @@ export interface Ticket {
export interface TicketMessage {
id: string;
ticketId: string;
userId: number;
userId: string;
message: string;
createdAt: string;
}
export const ticketsApi = {
create: (data: { subject: string; priority: string; message?: string }) => {
create: (data: { subject: string; category: string; priority: string; message?: string }) => {
return apiRequest<Ticket>("/api/v1/support/tickets", {
method: "POST",
body: JSON.stringify(data),