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
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user