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
242 lines
6.6 KiB
Go
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
|
|
}
|