feat: add category field to tickets system
This commit is contained in:
parent
3583ef89d8
commit
74afffa4a9
9 changed files with 67 additions and 37 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
5
backend/migrations/044_add_category_to_tickets.sql
Normal file
5
backend/migrations/044_add_category_to_tickets.sql
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue