9e519f5c8f
- Add database migration: contacts, conversations, messages, leads, workflows, follow-ups, audit log - Contact handler with deduplication and identity resolution - Conversation handler with unified inbox - Lead handler with scoring engine and qualification - Workflow handler with trigger-action engine - Full API routes for all comm layer endpoints - 12 operational agents integrated in all BOC modules
341 lines
11 KiB
Go
341 lines
11 KiB
Go
package comm
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// ConversationHandler hanterar konversationer
|
|
type ConversationHandler struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// NewConversationHandler skapar en ny handler
|
|
func NewConversationHandler(db *sql.DB) *ConversationHandler {
|
|
return &ConversationHandler{db: db}
|
|
}
|
|
|
|
// Conversation representerar en konversation
|
|
type Conversation struct {
|
|
ID uuid.UUID `json:"id"`
|
|
TenantID uuid.UUID `json:"tenant_id"`
|
|
ContactID uuid.UUID `json:"contact_id"`
|
|
Channel string `json:"channel"`
|
|
Status string `json:"status"`
|
|
AssignedTo *uuid.UUID `json:"assigned_to,omitempty"`
|
|
AssignedTeam string `json:"assigned_team,omitempty"`
|
|
LeadID *uuid.UUID `json:"lead_id,omitempty"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
Priority string `json:"priority"`
|
|
LastActivityAt time.Time `json:"last_activity_at"`
|
|
NextActionAt *time.Time `json:"next_action_at,omitempty"`
|
|
NextActionType string `json:"next_action_type,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Contact *ContactBrief `json:"contact,omitempty"`
|
|
MessageCount int `json:"message_count"`
|
|
UnreadCount int `json:"unread_count"`
|
|
}
|
|
|
|
type ContactBrief struct {
|
|
ID uuid.UUID `json:"id"`
|
|
FirstName string `json:"first_name"`
|
|
LastName string `json:"last_name"`
|
|
Email string `json:"email"`
|
|
Phone string `json:"phone"`
|
|
}
|
|
|
|
// Message representerar ett meddelande
|
|
type Message struct {
|
|
ID uuid.UUID `json:"id"`
|
|
ConversationID uuid.UUID `json:"conversation_id"`
|
|
ContactID uuid.UUID `json:"contact_id"`
|
|
Direction string `json:"direction"`
|
|
Content string `json:"content"`
|
|
ContentType string `json:"content_type"`
|
|
SenderType string `json:"sender_type"`
|
|
SenderID *uuid.UUID `json:"sender_id,omitempty"`
|
|
ReadAt *time.Time `json:"read_at,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// CreateConversationRequest
|
|
type CreateConversationRequest struct {
|
|
ContactID uuid.UUID `json:"contact_id"`
|
|
Channel string `json:"channel"`
|
|
ChannelID string `json:"channel_conversation_id,omitempty"`
|
|
Priority string `json:"priority,omitempty"`
|
|
AssignedTo uuid.UUID `json:"assigned_to,omitempty"`
|
|
AssignedTeam string `json:"assigned_team,omitempty"`
|
|
}
|
|
|
|
// SendMessageRequest
|
|
type SendMessageRequest struct {
|
|
Content string `json:"content"`
|
|
ContentType string `json:"content_type,omitempty"`
|
|
Direction string `json:"direction"`
|
|
SenderType string `json:"sender_type,omitempty"`
|
|
}
|
|
|
|
// CreateConversation skapar en ny konversation
|
|
func (h *ConversationHandler) CreateConversation(w http.ResponseWriter, r *http.Request) {
|
|
var req CreateConversationRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request")
|
|
return
|
|
}
|
|
|
|
tenantID := getTenantID(r)
|
|
if tenantID == uuid.Nil {
|
|
writeError(w, http.StatusUnauthorized, "missing tenant")
|
|
return
|
|
}
|
|
|
|
var conv Conversation
|
|
err := h.db.QueryRow(`
|
|
INSERT INTO conversations (tenant_id, contact_id, channel, channel_conversation_id, status, priority, assigned_to, assigned_team)
|
|
VALUES ($1, $2, $3, $4, 'new', COALESCE($5, 'normal'), $6, $7)
|
|
RETURNING id, tenant_id, contact_id, channel, status, assigned_to, assigned_team, priority, last_activity_at, created_at, updated_at
|
|
`, tenantID, req.ContactID, req.Channel, req.ChannelID, req.Priority,
|
|
nullUUID(req.AssignedTo), nullString(req.AssignedTeam)).Scan(
|
|
&conv.ID, &conv.TenantID, &conv.ContactID, &conv.Channel, &conv.Status,
|
|
&conv.AssignedTo, &conv.AssignedTeam, &conv.Priority, &conv.LastActivityAt, &conv.CreatedAt, &conv.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to create conversation")
|
|
writeError(w, http.StatusInternalServerError, "failed to create conversation")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, conv)
|
|
}
|
|
|
|
// ListConversations listar konversationer med filtering
|
|
func (h *ConversationHandler) ListConversations(w http.ResponseWriter, r *http.Request) {
|
|
tenantID := getTenantID(r)
|
|
if tenantID == uuid.Nil {
|
|
writeError(w, http.StatusUnauthorized, "missing tenant")
|
|
return
|
|
}
|
|
|
|
status := r.URL.Query().Get("status")
|
|
assignedTo := r.URL.Query().Get("assigned_to")
|
|
channel := r.URL.Query().Get("channel")
|
|
|
|
query := `
|
|
SELECT c.id, c.tenant_id, c.contact_id, c.channel, c.status, c.assigned_to, c.assigned_team,
|
|
c.priority, c.last_activity_at, c.next_action_at, c.next_action_type, c.created_at, c.updated_at,
|
|
co.id, co.first_name, co.last_name, co.email, co.phone,
|
|
(SELECT COUNT(*) FROM messages WHERE conversation_id = c.id) as message_count,
|
|
(SELECT COUNT(*) FROM messages WHERE conversation_id = c.id AND direction = 'inbound' AND read_at IS NULL) as unread_count
|
|
FROM conversations c
|
|
JOIN contacts co ON c.contact_id = co.id
|
|
WHERE c.tenant_id = $1
|
|
`
|
|
args := []interface{}{tenantID}
|
|
argCount := 1
|
|
|
|
if status != "" {
|
|
argCount++
|
|
query += " AND c.status = $" + string(rune('0'+argCount))
|
|
args = append(args, status)
|
|
}
|
|
if assignedTo != "" {
|
|
argCount++
|
|
query += " AND c.assigned_to = $" + string(rune('0'+argCount))
|
|
args = append(args, assignedTo)
|
|
}
|
|
if channel != "" {
|
|
argCount++
|
|
query += " AND c.channel = $" + string(rune('0'+argCount))
|
|
args = append(args, channel)
|
|
}
|
|
|
|
query += " ORDER BY c.last_activity_at DESC LIMIT 100"
|
|
|
|
rows, err := h.db.Query(query, args...)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var conversations []Conversation
|
|
for rows.Next() {
|
|
var conv Conversation
|
|
var contact ContactBrief
|
|
rows.Scan(
|
|
&conv.ID, &conv.TenantID, &conv.ContactID, &conv.Channel, &conv.Status,
|
|
&conv.AssignedTo, &conv.AssignedTeam, &conv.Priority, &conv.LastActivityAt,
|
|
&conv.NextActionAt, &conv.NextActionType, &conv.CreatedAt, &conv.UpdatedAt,
|
|
&contact.ID, &contact.FirstName, &contact.LastName, &contact.Email, &contact.Phone,
|
|
&conv.MessageCount, &conv.UnreadCount,
|
|
)
|
|
conv.Contact = &contact
|
|
conversations = append(conversations, conv)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"conversations": conversations})
|
|
}
|
|
|
|
// GetConversation hämtar en konversation med meddelanden
|
|
func (h *ConversationHandler) GetConversation(w http.ResponseWriter, r *http.Request) {
|
|
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid id")
|
|
return
|
|
}
|
|
|
|
var conv Conversation
|
|
err = h.db.QueryRow(`
|
|
SELECT id, tenant_id, contact_id, channel, status, assigned_to, assigned_team,
|
|
priority, last_activity_at, next_action_at, next_action_type, created_at, updated_at
|
|
FROM conversations WHERE id = $1
|
|
`, id).Scan(
|
|
&conv.ID, &conv.TenantID, &conv.ContactID, &conv.Channel, &conv.Status,
|
|
&conv.AssignedTo, &conv.AssignedTeam, &conv.Priority, &conv.LastActivityAt,
|
|
&conv.NextActionAt, &conv.NextActionType, &conv.CreatedAt, &conv.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "conversation not found")
|
|
return
|
|
}
|
|
|
|
// Hämta meddelanden
|
|
rows, err := h.db.Query(`
|
|
SELECT id, conversation_id, contact_id, direction, content, content_type,
|
|
sender_type, sender_id, read_at, created_at
|
|
FROM messages
|
|
WHERE conversation_id = $1
|
|
ORDER BY created_at ASC
|
|
`, id)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var messages []Message
|
|
for rows.Next() {
|
|
var msg Message
|
|
rows.Scan(&msg.ID, &msg.ConversationID, &msg.ContactID, &msg.Direction, &msg.Content,
|
|
&msg.ContentType, &msg.SenderType, &msg.SenderID, &msg.ReadAt, &msg.CreatedAt)
|
|
messages = append(messages, msg)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"conversation": conv,
|
|
"messages": messages,
|
|
})
|
|
}
|
|
|
|
// SendMessage skickar ett meddelande i en konversation
|
|
func (h *ConversationHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
|
|
convID, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid conversation id")
|
|
return
|
|
}
|
|
|
|
var req SendMessageRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request")
|
|
return
|
|
}
|
|
|
|
// Hämta conversation för att få contact_id
|
|
var contactID uuid.UUID
|
|
err = h.db.QueryRow("SELECT contact_id FROM conversations WHERE id = $1", convID).Scan(&contactID)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "conversation not found")
|
|
return
|
|
}
|
|
|
|
// Sätt sender från auth context
|
|
var senderID *uuid.UUID
|
|
if userID, ok := r.Context().Value("user_id").(uuid.UUID); ok {
|
|
senderID = &userID
|
|
}
|
|
|
|
var msg Message
|
|
err = h.db.QueryRow(`
|
|
INSERT INTO messages (conversation_id, contact_id, direction, content, content_type, sender_type, sender_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id, conversation_id, contact_id, direction, content, content_type, sender_type, sender_id, created_at
|
|
`, convID, contactID, req.Direction, req.Content, req.ContentType, req.SenderType, senderID).Scan(
|
|
&msg.ID, &msg.ConversationID, &msg.ContactID, &msg.Direction, &msg.Content,
|
|
&msg.ContentType, &msg.SenderType, &msg.SenderID, &msg.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to create message")
|
|
writeError(w, http.StatusInternalServerError, "failed to create message")
|
|
return
|
|
}
|
|
|
|
// Uppdatera conversation last_activity
|
|
h.db.Exec(`
|
|
UPDATE conversations
|
|
SET last_activity_at = NOW(), updated_at = NOW()
|
|
WHERE id = $1
|
|
`, convID)
|
|
|
|
writeJSON(w, http.StatusCreated, msg)
|
|
}
|
|
|
|
// UpdateConversationStatus uppdaterar status
|
|
func (h *ConversationHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
|
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid id")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Status string `json:"status"`
|
|
AssignedTo uuid.UUID `json:"assigned_to,omitempty"`
|
|
AssignedTeam string `json:"assigned_team,omitempty"`
|
|
Priority string `json:"priority,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request")
|
|
return
|
|
}
|
|
|
|
_, err = h.db.Exec(`
|
|
UPDATE conversations
|
|
SET status = COALESCE(NULLIF($1, ''), status),
|
|
assigned_to = COALESCE($2, assigned_to),
|
|
assigned_team = COALESCE(NULLIF($3, ''), assigned_team),
|
|
priority = COALESCE(NULLIF($4, ''), priority),
|
|
updated_at = NOW()
|
|
WHERE id = $5
|
|
`, req.Status, nullUUID(req.AssignedTo), req.AssignedTeam, req.Priority, id)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "update failed")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"updated": true})
|
|
}
|
|
|
|
// Helper functions
|
|
func nullUUID(u uuid.UUID) interface{} {
|
|
if u == uuid.Nil {
|
|
return nil
|
|
}
|
|
return u
|
|
}
|
|
|
|
func nullString(s string) interface{} {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|