Files
boc/backend/handlers/comm/conversation.go
T
Bernt 9e519f5c8f
BOC CI/CD / Test (push) Failing after 2s
BOC CI/CD / Security Scan (push) Has been skipped
BOC CI/CD / Build & Push (push) Has been skipped
BOC CI/CD / Deploy to Staging (push) Has been skipped
BOC CI/CD / Deploy to Production (push) Has been skipped
feat(comm): implement Communication & Lead Layer core
- 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
2026-08-12 10:15:54 +00:00

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
}