gohorsejobs/backend/internal/services/ticket_service.go
Tiago Yamamoto 9ee9f6855c feat: implementar múltiplas features
Backend:
- Password reset flow (forgot/reset endpoints, tokens table)
- Profile management (PUT /users/me, skills, experience, education)
- Tickets system (CRUD, messages, stats)
- Activity logs (list, stats)
- Document validator (CNPJ, CPF, EIN support)
- Input sanitizer (XSS prevention)
- Full-text search em vagas (plainto_tsquery)
- Filtros avançados (location, salary, workMode)
- Ordenação (date, salary, relevance)

Frontend:
- Forgot/Reset password pages
- Candidate profile edit page
- Sanitize utilities (sanitize.ts)

Backoffice:
- TicketsModule proxy
- ActivityLogsModule proxy
- Dockerfile otimizado (multi-stage, non-root, healthcheck)

Migrations:
- 013: Profile fields to users
- 014: Password reset tokens
- 015: Tickets table
- 016: Activity logs table
2025-12-27 11:19:47 -03:00

242 lines
6.6 KiB
Go

package services
import (
"database/sql"
"github.com/rede5/gohorsejobs/backend/internal/models"
)
type TicketService struct {
db *sql.DB
}
func NewTicketService(db *sql.DB) *TicketService {
return &TicketService{db: db}
}
// Create creates a new ticket
func (s *TicketService) Create(userID *int, companyID *int, req models.CreateTicketRequest) (*models.Ticket, error) {
query := `
INSERT INTO tickets (user_id, company_id, subject, description, category, priority)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, status, created_at, updated_at
`
category := req.Category
if category == "" {
category = "general"
}
priority := req.Priority
if priority == "" {
priority = "medium"
}
ticket := &models.Ticket{
UserID: userID,
CompanyID: companyID,
Subject: req.Subject,
Description: req.Description,
Category: category,
Priority: priority,
}
err := s.db.QueryRow(query, userID, companyID, req.Subject, req.Description, category, priority).
Scan(&ticket.ID, &ticket.Status, &ticket.CreatedAt, &ticket.UpdatedAt)
if err != nil {
return nil, err
}
return ticket, nil
}
// List lists all tickets with optional filters
func (s *TicketService) List(status, priority string, limit, offset int) ([]models.Ticket, error) {
query := `
SELECT t.id, t.user_id, t.company_id, t.subject, t.description, t.category,
t.priority, t.status, t.assigned_to, t.created_at, t.updated_at, t.resolved_at,
u.full_name as user_name, c.name as company_name
FROM tickets t
LEFT JOIN users u ON t.user_id = u.id
LEFT JOIN companies c ON t.company_id = c.id
WHERE ($1 = '' OR t.status = $1)
AND ($2 = '' OR t.priority = $2)
ORDER BY
CASE t.priority
WHEN 'urgent' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
ELSE 4
END,
t.created_at DESC
LIMIT $3 OFFSET $4
`
rows, err := s.db.Query(query, status, priority, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var tickets []models.Ticket
for rows.Next() {
var t models.Ticket
err := rows.Scan(
&t.ID, &t.UserID, &t.CompanyID, &t.Subject, &t.Description, &t.Category,
&t.Priority, &t.Status, &t.AssignedTo, &t.CreatedAt, &t.UpdatedAt, &t.ResolvedAt,
&t.UserName, &t.CompanyName,
)
if err != nil {
return nil, err
}
tickets = append(tickets, t)
}
return tickets, nil
}
// GetByID gets a ticket by ID
func (s *TicketService) GetByID(id int) (*models.Ticket, error) {
query := `
SELECT t.id, t.user_id, t.company_id, t.subject, t.description, t.category,
t.priority, t.status, t.assigned_to, t.created_at, t.updated_at, t.resolved_at,
u.full_name as user_name, c.name as company_name
FROM tickets t
LEFT JOIN users u ON t.user_id = u.id
LEFT JOIN companies c ON t.company_id = c.id
WHERE t.id = $1
`
var t models.Ticket
err := s.db.QueryRow(query, id).Scan(
&t.ID, &t.UserID, &t.CompanyID, &t.Subject, &t.Description, &t.Category,
&t.Priority, &t.Status, &t.AssignedTo, &t.CreatedAt, &t.UpdatedAt, &t.ResolvedAt,
&t.UserName, &t.CompanyName,
)
if err != nil {
return nil, err
}
return &t, nil
}
// Update updates a ticket
func (s *TicketService) Update(id int, req models.UpdateTicketRequest) (*models.Ticket, error) {
query := `
UPDATE tickets SET
status = COALESCE($1, status),
priority = COALESCE($2, priority),
assigned_to = COALESCE($3, assigned_to),
updated_at = NOW(),
resolved_at = CASE WHEN $1 IN ('resolved', 'closed') THEN NOW() ELSE resolved_at END
WHERE id = $4
RETURNING id, user_id, company_id, subject, description, category, priority, status, assigned_to, created_at, updated_at, resolved_at
`
var t models.Ticket
err := s.db.QueryRow(query, req.Status, req.Priority, req.AssignedTo, id).Scan(
&t.ID, &t.UserID, &t.CompanyID, &t.Subject, &t.Description, &t.Category,
&t.Priority, &t.Status, &t.AssignedTo, &t.CreatedAt, &t.UpdatedAt, &t.ResolvedAt,
)
if err != nil {
return nil, err
}
return &t, nil
}
// AddMessage adds a message to a ticket
func (s *TicketService) AddMessage(ticketID int, userID *int, req models.AddTicketMessageRequest) (*models.TicketMessage, error) {
query := `
INSERT INTO ticket_messages (ticket_id, user_id, message, is_internal)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at
`
msg := &models.TicketMessage{
TicketID: ticketID,
UserID: userID,
Message: req.Message,
IsInternal: req.IsInternal,
}
err := s.db.QueryRow(query, ticketID, userID, req.Message, req.IsInternal).
Scan(&msg.ID, &msg.CreatedAt)
if err != nil {
return nil, err
}
// Update ticket updated_at
_, _ = s.db.Exec("UPDATE tickets SET updated_at = NOW() WHERE id = $1", ticketID)
return msg, nil
}
// GetMessages gets all messages for a ticket
func (s *TicketService) GetMessages(ticketID int, includeInternal bool) ([]models.TicketMessage, error) {
query := `
SELECT tm.id, tm.ticket_id, tm.user_id, tm.message, tm.is_internal, tm.created_at,
u.full_name as user_name
FROM ticket_messages tm
LEFT JOIN users u ON tm.user_id = u.id
WHERE tm.ticket_id = $1 AND ($2 OR tm.is_internal = false)
ORDER BY tm.created_at ASC
`
rows, err := s.db.Query(query, ticketID, includeInternal)
if err != nil {
return nil, err
}
defer rows.Close()
var messages []models.TicketMessage
for rows.Next() {
var m models.TicketMessage
err := rows.Scan(&m.ID, &m.TicketID, &m.UserID, &m.Message, &m.IsInternal, &m.CreatedAt, &m.UserName)
if err != nil {
return nil, err
}
messages = append(messages, m)
}
return messages, nil
}
// GetStats gets ticket statistics
func (s *TicketService) GetStats() (*models.TicketStats, error) {
stats := &models.TicketStats{}
// Count by status
query := `
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status = 'open') as open,
COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress,
COUNT(*) FILTER (WHERE status IN ('resolved', 'closed')) as resolved
FROM tickets
`
err := s.db.QueryRow(query).Scan(&stats.Total, &stats.Open, &stats.InProgress, &stats.Resolved)
if err != nil {
return nil, err
}
// Calculate average response time (from creation to first message)
responseQuery := `
SELECT COALESCE(
AVG(EXTRACT(EPOCH FROM (
(SELECT MIN(created_at) FROM ticket_messages WHERE ticket_id = t.id) - t.created_at
)) / 3600), 0
) as avg_response
FROM tickets t
WHERE EXISTS (SELECT 1 FROM ticket_messages WHERE ticket_id = t.id)
`
_ = s.db.QueryRow(responseQuery).Scan(&stats.AvgResponse)
return stats, nil
}
// DeleteTicket deletes a ticket (soft delete by setting status to 'closed')
func (s *TicketService) Delete(id int) error {
_, err := s.db.Exec(`UPDATE tickets SET status = 'closed', updated_at = NOW() WHERE id = $1`, id)
return err
}