diff --git a/backend/bin/boc b/backend/bin/boc index c92bd5daa..353f692e4 100755 Binary files a/backend/bin/boc and b/backend/bin/boc differ diff --git a/backend/boc-api b/backend/boc-api index c92bd5daa..353f692e4 100755 Binary files a/backend/boc-api and b/backend/boc-api differ diff --git a/backend/db/migrations/2025081201_communication_layer.sql b/backend/db/migrations/2025081201_communication_layer.sql new file mode 100644 index 000000000..533bdb1c3 --- /dev/null +++ b/backend/db/migrations/2025081201_communication_layer.sql @@ -0,0 +1,234 @@ +-- Communication & Lead Layer - Core Tables +-- Migration: 2025081201 + +-- Contact Identity - central contact model +CREATE TABLE IF NOT EXISTS contacts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + first_name VARCHAR(100), + last_name VARCHAR(100), + email VARCHAR(255), + phone VARCHAR(50), + whatsapp_id VARCHAR(100), + instagram_id VARCHAR(100), + facebook_id VARCHAR(100), + external_id VARCHAR(100), + company VARCHAR(255), + org_number VARCHAR(50), + tags TEXT[], + status VARCHAR(50) DEFAULT 'active', + lead_score INTEGER DEFAULT 0, + source VARCHAR(100), + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + merged_into UUID REFERENCES contacts(id), + UNIQUE(tenant_id, email), + UNIQUE(tenant_id, phone) +); + +-- Contact Identity Links - for deduplication +CREATE TABLE IF NOT EXISTS contact_identity_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, + channel VARCHAR(50) NOT NULL, -- 'whatsapp', 'email', 'phone', 'instagram', etc. + channel_id VARCHAR(255) NOT NULL, + confidence FLOAT DEFAULT 1.0, + verified BOOLEAN DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(contact_id, channel, channel_id) +); + +-- Conversations +CREATE TABLE IF NOT EXISTS conversations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + contact_id UUID NOT NULL REFERENCES contacts(id), + channel VARCHAR(50) NOT NULL, + channel_conversation_id VARCHAR(255), + status VARCHAR(50) DEFAULT 'new', -- 'new', 'active', 'waiting', 'resolved', 'escalated' + assigned_to UUID, + assigned_team VARCHAR(100), + lead_id UUID, + tags TEXT[], + priority VARCHAR(20) DEFAULT 'normal', -- 'low', 'normal', 'high', 'urgent' + last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + next_action_at TIMESTAMP WITH TIME ZONE, + next_action_type VARCHAR(100), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + metadata JSONB DEFAULT '{}' +); + +-- Messages +CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + contact_id UUID NOT NULL REFERENCES contacts(id), + direction VARCHAR(20) NOT NULL, -- 'inbound', 'outbound' + content TEXT NOT NULL, + content_type VARCHAR(50) DEFAULT 'text', -- 'text', 'image', 'file', 'template' + channel_message_id VARCHAR(255), + sender_type VARCHAR(50) DEFAULT 'contact', -- 'contact', 'agent', 'system', 'user' + sender_id UUID, + read_at TIMESTAMP WITH TIME ZONE, + delivered_at TIMESTAMP WITH TIME ZONE, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Leads +CREATE TABLE IF NOT EXISTS leads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + contact_id UUID NOT NULL REFERENCES contacts(id), + conversation_id UUID REFERENCES conversations(id), + source VARCHAR(100) NOT NULL, + source_metadata JSONB DEFAULT '{}', + status VARCHAR(50) DEFAULT 'new', -- 'new', 'contacted', 'qualifying', 'qualified', 'sales', 'won', 'lost' + interest VARCHAR(255), + product VARCHAR(255), + urgency VARCHAR(20) DEFAULT 'normal', -- 'low', 'normal', 'high', 'urgent' + customer_type VARCHAR(50), -- 'new', 'existing' + owner UUID, + team VARCHAR(100), + lead_score INTEGER DEFAULT 0, + qualification_state JSONB DEFAULT '{}', + qualification_complete BOOLEAN DEFAULT false, + last_interaction_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + next_action_at TIMESTAMP WITH TIME ZONE, + next_action_type VARCHAR(100), + won_at TIMESTAMP WITH TIME ZONE, + lost_at TIMESTAMP WITH TIME ZONE, + lost_reason VARCHAR(255), + tags TEXT[], + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Lead Score History +CREATE TABLE IF NOT EXISTS lead_score_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE, + score INTEGER NOT NULL, + previous_score INTEGER, + reason TEXT NOT NULL, + factors JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Tags +CREATE TABLE IF NOT EXISTS tags ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + name VARCHAR(100) NOT NULL, + color VARCHAR(20) DEFAULT '#2563EB', + category VARCHAR(100), + description TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(tenant_id, name) +); + +-- Workflows +CREATE TABLE IF NOT EXISTS workflows ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + trigger VARCHAR(100) NOT NULL, -- 'new_contact', 'new_lead', 'tag_added', 'status_changed', etc. + trigger_config JSONB DEFAULT '{}', + conditions JSONB DEFAULT '[]', + actions JSONB DEFAULT '[]', + status VARCHAR(50) DEFAULT 'active', -- 'active', 'paused', 'draft' + execution_count INTEGER DEFAULT 0, + last_executed_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Workflow Executions +CREATE TABLE IF NOT EXISTS workflow_executions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workflow_id UUID NOT NULL REFERENCES workflows(id), + trigger_event VARCHAR(100) NOT NULL, + trigger_data JSONB DEFAULT '{}', + status VARCHAR(50) DEFAULT 'running', -- 'running', 'completed', 'failed', 'waiting' + current_step INTEGER DEFAULT 0, + steps JSONB DEFAULT '[]', + result JSONB DEFAULT '{}', + error TEXT, + started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + completed_at TIMESTAMP WITH TIME ZONE +); + +-- Follow-ups +CREATE TABLE IF NOT EXISTS follow_ups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + lead_id UUID REFERENCES leads(id), + conversation_id UUID REFERENCES conversations(id), + type VARCHAR(100) NOT NULL, + description TEXT, + assigned_to UUID, + due_at TIMESTAMP WITH TIME ZONE NOT NULL, + completed_at TIMESTAMP WITH TIME ZONE, + status VARCHAR(50) DEFAULT 'pending', -- 'pending', 'completed', 'overdue', 'cancelled' + reminder_sent BOOLEAN DEFAULT false, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Audit Log +CREATE TABLE IF NOT EXISTS communication_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + actor_type VARCHAR(50) NOT NULL, -- 'user', 'system', 'agent', 'workflow' + actor_id UUID, + action VARCHAR(100) NOT NULL, + object_type VARCHAR(100) NOT NULL, -- 'contact', 'lead', 'conversation', 'message', 'workflow' + object_id UUID NOT NULL, + previous_state JSONB, + new_state JSONB, + source VARCHAR(100), + correlation_id UUID, + ip_address INET, + user_agent TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_contacts_tenant ON contacts(tenant_id); +CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email); +CREATE INDEX IF NOT EXISTS idx_contacts_phone ON contacts(phone); +CREATE INDEX IF NOT EXISTS idx_contacts_merged ON contacts(merged_into); + +CREATE INDEX IF NOT EXISTS idx_contact_links_contact ON contact_identity_links(contact_id); +CREATE INDEX IF NOT EXISTS idx_contact_links_channel ON contact_identity_links(channel, channel_id); + +CREATE INDEX IF NOT EXISTS idx_conversations_tenant ON conversations(tenant_id); +CREATE INDEX IF NOT EXISTS idx_conversations_contact ON conversations(contact_id); +CREATE INDEX IF NOT EXISTS idx_conversations_status ON conversations(status); +CREATE INDEX IF NOT EXISTS idx_conversations_assigned ON conversations(assigned_to); +CREATE INDEX IF NOT EXISTS idx_conversations_lead ON conversations(lead_id); + +CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id); +CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at); + +CREATE INDEX IF NOT EXISTS idx_leads_tenant ON leads(tenant_id); +CREATE INDEX IF NOT EXISTS idx_leads_contact ON leads(contact_id); +CREATE INDEX IF NOT EXISTS idx_leads_status ON leads(status); +CREATE INDEX IF NOT EXISTS idx_leads_owner ON leads(owner); +CREATE INDEX IF NOT EXISTS idx_leads_score ON leads(lead_score DESC); + +CREATE INDEX IF NOT EXISTS idx_workflows_tenant ON workflows(tenant_id); +CREATE INDEX IF NOT EXISTS idx_workflows_trigger ON workflows(trigger); + +CREATE INDEX IF NOT EXISTS idx_followups_lead ON follow_ups(lead_id); +CREATE INDEX IF NOT EXISTS idx_followups_due ON follow_ups(due_at); +CREATE INDEX IF NOT EXISTS idx_followups_status ON follow_ups(status); + +CREATE INDEX IF NOT EXISTS idx_audit_tenant ON communication_audit_log(tenant_id); +CREATE INDEX IF NOT EXISTS idx_audit_object ON communication_audit_log(object_type, object_id); +CREATE INDEX IF NOT EXISTS idx_audit_correlation ON communication_audit_log(correlation_id); +CREATE INDEX IF NOT EXISTS idx_audit_created ON communication_audit_log(created_at); diff --git a/backend/handlers/comm/contact.go b/backend/handlers/comm/contact.go new file mode 100644 index 000000000..6a43a26ad --- /dev/null +++ b/backend/handlers/comm/contact.go @@ -0,0 +1,257 @@ +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" +) + +// ContactHandler hanterar contact-identitet och deduplicering +type ContactHandler struct { + db *sql.DB +} + +// NewContactHandler skapar en ny handler +func NewContactHandler(db *sql.DB) *ContactHandler { + return &ContactHandler{db: db} +} + +// Contact representerar en kontakt +type Contact struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + Email string `json:"email,omitempty"` + Phone string `json:"phone,omitempty"` + Company string `json:"company,omitempty"` + Tags []string `json:"tags,omitempty"` + Status string `json:"status"` + LeadScore int `json:"lead_score"` + Source string `json:"source,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateContactRequest för att skapa kontakt +type CreateContactRequest struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + Phone string `json:"phone"` + Company string `json:"company"` + Source string `json:"source"` + Tags []string `json:"tags,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// CreateContact skapar en ny kontakt med deduplicering +func (h *ContactHandler) CreateContact(w http.ResponseWriter, r *http.Request) { + var req CreateContactRequest + 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 + } + + // Deduplicering: kolla om kontakt redan finns + existingID, confidence := h.findExistingContact(tenantID, req.Email, req.Phone) + if existingID != uuid.Nil && confidence > 0.8 { + // Uppdatera befintlig kontakt + h.updateContact(existingID, req) + contact, _ := h.getContact(existingID) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "contact": contact, + "merged": true, + "confidence": confidence, + }) + return + } + + // Skapa ny kontakt + contact, err := h.createNewContact(tenantID, req) + if err != nil { + log.Error().Err(err).Msg("Failed to create contact") + writeError(w, http.StatusInternalServerError, "failed to create contact") + return + } + + writeJSON(w, http.StatusCreated, map[string]interface{}{ + "contact": contact, + "merged": false, + "confidence": 1.0, + }) +} + +// GetContact hämtar en kontakt +func (h *ContactHandler) GetContact(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + + contact, err := h.getContact(id) + if err != nil { + writeError(w, http.StatusNotFound, "contact not found") + return + } + + writeJSON(w, http.StatusOK, contact) +} + +// ListContacts listar kontakter +func (h *ContactHandler) ListContacts(w http.ResponseWriter, r *http.Request) { + tenantID := getTenantID(r) + if tenantID == uuid.Nil { + writeError(w, http.StatusUnauthorized, "missing tenant") + return + } + + rows, err := h.db.Query(` + SELECT id, first_name, last_name, email, phone, company, tags, status, lead_score, source, created_at, updated_at + FROM contacts + WHERE tenant_id = $1 AND merged_into IS NULL + ORDER BY updated_at DESC + LIMIT 100 + `, tenantID) + if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + defer rows.Close() + + var contacts []Contact + for rows.Next() { + var c Contact + rows.Scan(&c.ID, &c.FirstName, &c.LastName, &c.Email, &c.Phone, &c.Company, &c.Tags, &c.Status, &c.LeadScore, &c.Source, &c.CreatedAt, &c.UpdatedAt) + contacts = append(contacts, c) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"contacts": contacts}) +} + +// MergeContacts slår ihop två kontakter +func (h *ContactHandler) MergeContacts(w http.ResponseWriter, r *http.Request) { + var req struct { + PrimaryID uuid.UUID `json:"primary_id"` + SecondaryID uuid.UUID `json:"secondary_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request") + return + } + + // Uppdatera secondary att peka på primary + _, err := h.db.Exec(` + UPDATE contacts SET merged_into = $1, status = 'merged' WHERE id = $2 + `, req.PrimaryID, req.SecondaryID) + if err != nil { + writeError(w, http.StatusInternalServerError, "merge failed") + return + } + + // Uppdatera conversations och leads + h.db.Exec(`UPDATE conversations SET contact_id = $1 WHERE contact_id = $2`, req.PrimaryID, req.SecondaryID) + h.db.Exec(`UPDATE leads SET contact_id = $1 WHERE contact_id = $2`, req.PrimaryID, req.SecondaryID) + + writeJSON(w, http.StatusOK, map[string]interface{}{"merged": true}) +} + +// Internal helpers + +func (h *ContactHandler) findExistingContact(tenantID uuid.UUID, email, phone string) (uuid.UUID, float64) { + if email != "" { + var id uuid.UUID + err := h.db.QueryRow(` + SELECT id FROM contacts + WHERE tenant_id = $1 AND email = $2 AND merged_into IS NULL + `, tenantID, email).Scan(&id) + if err == nil { + return id, 1.0 + } + } + + if phone != "" { + var id uuid.UUID + err := h.db.QueryRow(` + SELECT id FROM contacts + WHERE tenant_id = $1 AND phone = $2 AND merged_into IS NULL + `, tenantID, phone).Scan(&id) + if err == nil { + return id, 0.9 + } + } + + return uuid.Nil, 0 +} + +func (h *ContactHandler) createNewContact(tenantID uuid.UUID, req CreateContactRequest) (*Contact, error) { + metadata, _ := json.Marshal(req.Metadata) + + var contact Contact + err := h.db.QueryRow(` + INSERT INTO contacts (tenant_id, first_name, last_name, email, phone, company, source, tags, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, tenant_id, first_name, last_name, email, phone, company, tags, status, lead_score, source, created_at, updated_at + `, tenantID, req.FirstName, req.LastName, req.Email, req.Phone, req.Company, req.Source, req.Tags, metadata).Scan( + &contact.ID, &contact.TenantID, &contact.FirstName, &contact.LastName, &contact.Email, &contact.Phone, + &contact.Company, &contact.Tags, &contact.Status, &contact.LeadScore, &contact.Source, &contact.CreatedAt, &contact.UpdatedAt, + ) + + return &contact, err +} + +func (h *ContactHandler) updateContact(id uuid.UUID, req CreateContactRequest) error { + _, err := h.db.Exec(` + UPDATE contacts + SET first_name = COALESCE(NULLIF($1, ''), first_name), + last_name = COALESCE(NULLIF($2, ''), last_name), + company = COALESCE(NULLIF($3, ''), company), + updated_at = NOW() + WHERE id = $4 + `, req.FirstName, req.LastName, req.Company, id) + return err +} + +func (h *ContactHandler) getContact(id uuid.UUID) (*Contact, error) { + var contact Contact + err := h.db.QueryRow(` + SELECT id, tenant_id, first_name, last_name, email, phone, company, tags, status, lead_score, source, created_at, updated_at + FROM contacts WHERE id = $1 + `, id).Scan( + &contact.ID, &contact.TenantID, &contact.FirstName, &contact.LastName, &contact.Email, &contact.Phone, + &contact.Company, &contact.Tags, &contact.Status, &contact.LeadScore, &contact.Source, &contact.CreatedAt, &contact.UpdatedAt, + ) + return &contact, err +} + +// Helper functions +func writeJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +func getTenantID(r *http.Request) uuid.UUID { + // Hämta från context (satt av auth middleware) + if tenantID, ok := r.Context().Value("tenant_id").(uuid.UUID); ok { + return tenantID + } + return uuid.Nil +} diff --git a/backend/handlers/comm/conversation.go b/backend/handlers/comm/conversation.go new file mode 100644 index 000000000..6d2e173cc --- /dev/null +++ b/backend/handlers/comm/conversation.go @@ -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 +} diff --git a/backend/handlers/comm/lead.go b/backend/handlers/comm/lead.go new file mode 100644 index 000000000..2af80fa32 --- /dev/null +++ b/backend/handlers/comm/lead.go @@ -0,0 +1,485 @@ +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" +) + +// LeadHandler hanterar leads +type LeadHandler struct { + db *sql.DB +} + +// NewLeadHandler skapar en ny handler +func NewLeadHandler(db *sql.DB) *LeadHandler { + return &LeadHandler{db: db} +} + +// Lead representerar ett lead +type Lead struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + ContactID uuid.UUID `json:"contact_id"` + ConversationID *uuid.UUID `json:"conversation_id,omitempty"` + Source string `json:"source"` + SourceMetadata json.RawMessage `json:"source_metadata,omitempty"` + Status string `json:"status"` + Interest string `json:"interest,omitempty"` + Product string `json:"product,omitempty"` + Urgency string `json:"urgency"` + CustomerType string `json:"customer_type,omitempty"` + Owner *uuid.UUID `json:"owner,omitempty"` + Team string `json:"team,omitempty"` + LeadScore int `json:"lead_score"` + QualificationState json.RawMessage `json:"qualification_state,omitempty"` + QualificationComplete bool `json:"qualification_complete"` + LastInteractionAt time.Time `json:"last_interaction_at"` + NextActionAt *time.Time `json:"next_action_at,omitempty"` + NextActionType string `json:"next_action_type,omitempty"` + Tags []string `json:"tags,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Contact *ContactBrief `json:"contact,omitempty"` +} + +// CreateLeadRequest +type CreateLeadRequest struct { + ContactID uuid.UUID `json:"contact_id"` + ConversationID uuid.UUID `json:"conversation_id,omitempty"` + Source string `json:"source"` + SourceMetadata map[string]interface{} `json:"source_metadata,omitempty"` + Interest string `json:"interest,omitempty"` + Product string `json:"product,omitempty"` + Urgency string `json:"urgency,omitempty"` + CustomerType string `json:"customer_type,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +// UpdateLeadRequest +type UpdateLeadRequest struct { + Status string `json:"status,omitempty"` + Interest string `json:"interest,omitempty"` + Product string `json:"product,omitempty"` + Urgency string `json:"urgency,omitempty"` + Owner uuid.UUID `json:"owner,omitempty"` + Team string `json:"team,omitempty"` + Tags []string `json:"tags,omitempty"` + LeadScore int `json:"lead_score,omitempty"` +} + +// CreateLead skapar ett nytt lead +func (h *LeadHandler) CreateLead(w http.ResponseWriter, r *http.Request) { + var req CreateLeadRequest + 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 + } + + sourceMetadata, _ := json.Marshal(req.SourceMetadata) + + var lead Lead + err := h.db.QueryRow(` + INSERT INTO leads (tenant_id, contact_id, conversation_id, source, source_metadata, + interest, product, urgency, customer_type, tags, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, 'normal'), $9, $10, 'new') + RETURNING id, tenant_id, contact_id, conversation_id, source, status, interest, product, + urgency, customer_type, owner, team, lead_score, qualification_complete, + last_interaction_at, next_action_at, next_action_type, tags, created_at, updated_at + `, tenantID, req.ContactID, nullUUID(req.ConversationID), req.Source, sourceMetadata, + req.Interest, req.Product, req.Urgency, req.CustomerType, req.Tags).Scan( + &lead.ID, &lead.TenantID, &lead.ContactID, &lead.ConversationID, &lead.Source, &lead.Status, + &lead.Interest, &lead.Product, &lead.Urgency, &lead.CustomerType, &lead.Owner, &lead.Team, + &lead.LeadScore, &lead.QualificationComplete, &lead.LastInteractionAt, &lead.NextActionAt, + &lead.NextActionType, &lead.Tags, &lead.CreatedAt, &lead.UpdatedAt, + ) + if err != nil { + log.Error().Err(err).Msg("Failed to create lead") + writeError(w, http.StatusInternalServerError, "failed to create lead") + return + } + + // Beräkna initial lead score + score, factors := h.calculateLeadScore(lead) + if score > 0 { + h.updateLeadScore(lead.ID, score, factors, "Initial score on creation") + } + + writeJSON(w, http.StatusCreated, lead) +} + +// GetLead hämtar ett lead +func (h *LeadHandler) GetLead(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + + lead, err := h.getLeadWithContact(id) + if err != nil { + writeError(w, http.StatusNotFound, "lead not found") + return + } + + writeJSON(w, http.StatusOK, lead) +} + +// ListLeads listar leads med filtering +func (h *LeadHandler) ListLeads(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") + owner := r.URL.Query().Get("owner") + minScore := r.URL.Query().Get("min_score") + + query := ` + SELECT l.id, l.tenant_id, l.contact_id, l.conversation_id, l.source, l.status, + l.interest, l.product, l.urgency, l.customer_type, l.owner, l.team, + l.lead_score, l.qualification_complete, l.last_interaction_at, + l.next_action_at, l.next_action_type, l.tags, l.created_at, l.updated_at, + c.id, c.first_name, c.last_name, c.email, c.phone + FROM leads l + JOIN contacts c ON l.contact_id = c.id + WHERE l.tenant_id = $1 + ` + args := []interface{}{tenantID} + argCount := 1 + + if status != "" { + argCount++ + query += " AND l.status = $" + string(rune('0'+argCount)) + args = append(args, status) + } + if owner != "" { + argCount++ + query += " AND l.owner = $" + string(rune('0'+argCount)) + args = append(args, owner) + } + if minScore != "" { + argCount++ + query += " AND l.lead_score >= $" + string(rune('0'+argCount)) + args = append(args, minScore) + } + + query += " ORDER BY l.lead_score DESC, l.created_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 leads []Lead + for rows.Next() { + var lead Lead + var contact ContactBrief + rows.Scan( + &lead.ID, &lead.TenantID, &lead.ContactID, &lead.ConversationID, &lead.Source, &lead.Status, + &lead.Interest, &lead.Product, &lead.Urgency, &lead.CustomerType, &lead.Owner, &lead.Team, + &lead.LeadScore, &lead.QualificationComplete, &lead.LastInteractionAt, + &lead.NextActionAt, &lead.NextActionType, &lead.Tags, &lead.CreatedAt, &lead.UpdatedAt, + &contact.ID, &contact.FirstName, &contact.LastName, &contact.Email, &contact.Phone, + ) + lead.Contact = &contact + leads = append(leads, lead) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"leads": leads}) +} + +// UpdateLead uppdaterar ett lead +func (h *LeadHandler) UpdateLead(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 UpdateLeadRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request") + return + } + + // Hämta nuvarande lead för att logga förändringar + oldLead, _ := h.getLead(id) + + _, err = h.db.Exec(` + UPDATE leads + SET status = COALESCE(NULLIF($1, ''), status), + interest = COALESCE(NULLIF($2, ''), interest), + product = COALESCE(NULLIF($3, ''), product), + urgency = COALESCE(NULLIF($4, ''), urgency), + owner = COALESCE($5, owner), + team = COALESCE(NULLIF($6, ''), team), + tags = COALESCE($7, tags), + lead_score = COALESCE(NULLIF($8, 0), lead_score), + updated_at = NOW() + WHERE id = $9 + `, req.Status, req.Interest, req.Product, req.Urgency, + nullUUID(req.Owner), req.Team, req.Tags, req.LeadScore, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "update failed") + return + } + + // Om status ändrades, logga det + if oldLead != nil && req.Status != "" && req.Status != oldLead.Status { + h.logStatusChange(id, oldLead.Status, req.Status) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"updated": true}) +} + +// UpdateQualification uppdaterar kvalificeringsstatus +func (h *LeadHandler) UpdateQualification(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 { + State map[string]interface{} `json:"state"` + Complete bool `json:"complete"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request") + return + } + + stateJSON, _ := json.Marshal(req.State) + + _, err = h.db.Exec(` + UPDATE leads + SET qualification_state = $1, qualification_complete = $2, updated_at = NOW() + WHERE id = $3 + `, stateJSON, req.Complete, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "update failed") + return + } + + // Om kvalificering är komplett, uppdatera score + if req.Complete { + lead, _ := h.getLead(id) + if lead != nil { + score, factors := h.calculateLeadScore(*lead) + h.updateLeadScore(id, score, factors, "Qualification completed") + } + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"updated": true}) +} + +// GetLeadScoreHistory hämtar score-historik +func (h *LeadHandler) GetLeadScoreHistory(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + + rows, err := h.db.Query(` + SELECT id, lead_id, score, previous_score, reason, factors, created_at + FROM lead_score_history + WHERE lead_id = $1 + ORDER BY created_at DESC + `, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + defer rows.Close() + + var history []map[string]interface{} + for rows.Next() { + var entry struct { + ID uuid.UUID `json:"id"` + LeadID uuid.UUID `json:"lead_id"` + Score int `json:"score"` + PreviousScore *int `json:"previous_score,omitempty"` + Reason string `json:"reason"` + Factors json.RawMessage `json:"factors"` + CreatedAt time.Time `json:"created_at"` + } + rows.Scan(&entry.ID, &entry.LeadID, &entry.Score, &entry.PreviousScore, &entry.Reason, &entry.Factors, &entry.CreatedAt) + history = append(history, map[string]interface{}{ + "id": entry.ID, + "score": entry.Score, + "previous_score": entry.PreviousScore, + "reason": entry.Reason, + "factors": entry.Factors, + "created_at": entry.CreatedAt, + }) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"history": history}) +} + +// LeadScoringFactors förklarar score +type LeadScoringFactors struct { + SourceScore int `json:"source_score"` + InteractionScore int `json:"interaction_score"` + IntentScore int `json:"intent_score"` + UrgencyScore int `json:"urgency_score"` + ProductScore int `json:"product_score"` + CustomerScore int `json:"customer_score"` + QualificationScore int `json:"qualification_score"` +} + +// calculateLeadScore beräknar lead score +func (h *LeadHandler) calculateLeadScore(lead Lead) (int, LeadScoringFactors) { + factors := LeadScoringFactors{} + + // Source score + switch lead.Source { + case "referral", "website": + factors.SourceScore = 25 + case "whatsapp", "instagram", "facebook": + factors.SourceScore = 20 + case "campaign", "landing_page": + factors.SourceScore = 15 + default: + factors.SourceScore = 10 + } + + // Urgency score + switch lead.Urgency { + case "urgent": + factors.UrgencyScore = 20 + case "high": + factors.UrgencyScore = 15 + case "normal": + factors.UrgencyScore = 10 + default: + factors.UrgencyScore = 5 + } + + // Product interest score + if lead.Product != "" { + factors.ProductScore = 15 + } + + // Customer type score + if lead.CustomerType == "existing" { + factors.CustomerScore = 15 + } else { + factors.CustomerScore = 10 + } + + // Qualification score + if lead.QualificationComplete { + factors.QualificationScore = 20 + } else { + // Delvis kvalificering + var state map[string]interface{} + json.Unmarshal(lead.QualificationState, &state) + if len(state) > 0 { + factors.QualificationScore = 10 + } + } + + // Interaction score (baserat på konversation) + if lead.ConversationID != nil { + var msgCount int + h.db.QueryRow("SELECT COUNT(*) FROM messages WHERE conversation_id = $1", lead.ConversationID).Scan(&msgCount) + if msgCount > 5 { + factors.InteractionScore = 15 + } else if msgCount > 2 { + factors.InteractionScore = 10 + } else { + factors.InteractionScore = 5 + } + } + + total := factors.SourceScore + factors.InteractionScore + factors.IntentScore + + factors.UrgencyScore + factors.ProductScore + factors.CustomerScore + factors.QualificationScore + + // Cap at 100 + if total > 100 { + total = 100 + } + + return total, factors +} + +func (h *LeadHandler) updateLeadScore(leadID uuid.UUID, score int, factors LeadScoringFactors, reason string) { + // Hämta nuvarande score + var oldScore int + h.db.QueryRow("SELECT lead_score FROM leads WHERE id = $1", leadID).Scan(&oldScore) + + // Uppdatera lead + h.db.Exec("UPDATE leads SET lead_score = $1, updated_at = NOW() WHERE id = $2", score, leadID) + + // Logga förändring + factorsJSON, _ := json.Marshal(factors) + h.db.Exec(` + INSERT INTO lead_score_history (lead_id, score, previous_score, reason, factors) + VALUES ($1, $2, $3, $4, $5) + `, leadID, score, oldScore, reason, factorsJSON) +} + +func (h *LeadHandler) logStatusChange(leadID uuid.UUID, oldStatus, newStatus string) { + // TODO: Implementera audit logging + log.Info(). + Str("lead_id", leadID.String()). + Str("old_status", oldStatus). + Str("new_status", newStatus). + Msg("Lead status changed") +} + +func (h *LeadHandler) getLead(id uuid.UUID) (*Lead, error) { + var lead Lead + err := h.db.QueryRow(` + SELECT id, tenant_id, contact_id, conversation_id, source, status, interest, product, + urgency, customer_type, owner, team, lead_score, qualification_state, qualification_complete, + last_interaction_at, next_action_at, next_action_type, tags, created_at, updated_at + FROM leads WHERE id = $1 + `, id).Scan( + &lead.ID, &lead.TenantID, &lead.ContactID, &lead.ConversationID, &lead.Source, &lead.Status, + &lead.Interest, &lead.Product, &lead.Urgency, &lead.CustomerType, &lead.Owner, &lead.Team, + &lead.LeadScore, &lead.QualificationState, &lead.QualificationComplete, &lead.LastInteractionAt, + &lead.NextActionAt, &lead.NextActionType, &lead.Tags, &lead.CreatedAt, &lead.UpdatedAt, + ) + return &lead, err +} + +func (h *LeadHandler) getLeadWithContact(id uuid.UUID) (*Lead, error) { + var lead Lead + var contact ContactBrief + err := h.db.QueryRow(` + SELECT l.id, l.tenant_id, l.contact_id, l.conversation_id, l.source, l.status, l.interest, l.product, + l.urgency, l.customer_type, l.owner, l.team, l.lead_score, l.qualification_state, l.qualification_complete, + l.last_interaction_at, l.next_action_at, l.next_action_type, l.tags, l.created_at, l.updated_at, + c.id, c.first_name, c.last_name, c.email, c.phone + FROM leads l + JOIN contacts c ON l.contact_id = c.id + WHERE l.id = $1 + `, id).Scan( + &lead.ID, &lead.TenantID, &lead.ContactID, &lead.ConversationID, &lead.Source, &lead.Status, + &lead.Interest, &lead.Product, &lead.Urgency, &lead.CustomerType, &lead.Owner, &lead.Team, + &lead.LeadScore, &lead.QualificationState, &lead.QualificationComplete, &lead.LastInteractionAt, + &lead.NextActionAt, &lead.NextActionType, &lead.Tags, &lead.CreatedAt, &lead.UpdatedAt, + &contact.ID, &contact.FirstName, &contact.LastName, &contact.Email, &contact.Phone, + ) + lead.Contact = &contact + return &lead, err +} diff --git a/backend/handlers/comm/workflow.go b/backend/handlers/comm/workflow.go new file mode 100644 index 000000000..40e5ea2c3 --- /dev/null +++ b/backend/handlers/comm/workflow.go @@ -0,0 +1,453 @@ +package comm + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/rs/zerolog/log" +) + +// WorkflowHandler hanterar workflows +type WorkflowHandler struct { + db *sql.DB +} + +// NewWorkflowHandler skapar en ny handler +func NewWorkflowHandler(db *sql.DB) *WorkflowHandler { + return &WorkflowHandler{db: db} +} + +// Workflow representerar en workflow +type Workflow struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Trigger string `json:"trigger"` + TriggerConfig json.RawMessage `json:"trigger_config,omitempty"` + Conditions json.RawMessage `json:"conditions,omitempty"` + Actions json.RawMessage `json:"actions,omitempty"` + Status string `json:"status"` + ExecutionCount int `json:"execution_count"` + LastExecutedAt *time.Time `json:"last_executed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// WorkflowExecution representerar en workflow-körning +type WorkflowExecution struct { + ID uuid.UUID `json:"id"` + WorkflowID uuid.UUID `json:"workflow_id"` + TriggerEvent string `json:"trigger_event"` + TriggerData json.RawMessage `json:"trigger_data,omitempty"` + Status string `json:"status"` + CurrentStep int `json:"current_step"` + Steps json.RawMessage `json:"steps,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error string `json:"error,omitempty"` + StartedAt time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + +// CreateWorkflowRequest +type CreateWorkflowRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Trigger string `json:"trigger"` + TriggerConfig map[string]interface{} `json:"trigger_config,omitempty"` + Conditions []WorkflowCondition `json:"conditions,omitempty"` + Actions []WorkflowAction `json:"actions"` +} + +// WorkflowCondition representerar ett villkor +type WorkflowCondition struct { + Field string `json:"field"` + Operator string `json:"operator"` // 'eq', 'ne', 'gt', 'lt', 'contains', 'exists' + Value interface{} `json:"value"` +} + +// WorkflowAction representerar en åtgärd +type WorkflowAction struct { + Type string `json:"type"` // 'create_contact', 'create_lead', 'update_lead', 'add_tag', 'assign_owner', 'create_task', 'send_message', 'change_status', 'escalate', 'webhook', 'wait' + Config map[string]interface{} `json:"config"` +} + +// CreateWorkflow skapar en ny workflow +func (h *WorkflowHandler) CreateWorkflow(w http.ResponseWriter, r *http.Request) { + var req CreateWorkflowRequest + 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 + } + + triggerConfig, _ := json.Marshal(req.TriggerConfig) + conditions, _ := json.Marshal(req.Conditions) + actions, _ := json.Marshal(req.Actions) + + var workflow Workflow + err := h.db.QueryRow(` + INSERT INTO workflows (tenant_id, name, description, trigger, trigger_config, conditions, actions, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'active') + RETURNING id, tenant_id, name, description, trigger, trigger_config, conditions, actions, status, created_at, updated_at + `, tenantID, req.Name, req.Description, req.Trigger, triggerConfig, conditions, actions).Scan( + &workflow.ID, &workflow.TenantID, &workflow.Name, &workflow.Description, &workflow.Trigger, + &workflow.TriggerConfig, &workflow.Conditions, &workflow.Actions, &workflow.Status, + &workflow.CreatedAt, &workflow.UpdatedAt, + ) + if err != nil { + log.Error().Err(err).Msg("Failed to create workflow") + writeError(w, http.StatusInternalServerError, "failed to create workflow") + return + } + + writeJSON(w, http.StatusCreated, workflow) +} + +// ListWorkflows listar workflows +func (h *WorkflowHandler) ListWorkflows(w http.ResponseWriter, r *http.Request) { + tenantID := getTenantID(r) + if tenantID == uuid.Nil { + writeError(w, http.StatusUnauthorized, "missing tenant") + return + } + + rows, err := h.db.Query(` + SELECT id, tenant_id, name, description, trigger, status, execution_count, last_executed_at, created_at, updated_at + FROM workflows + WHERE tenant_id = $1 + ORDER BY created_at DESC + `, tenantID) + if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + defer rows.Close() + + var workflows []Workflow + for rows.Next() { + var w Workflow + rows.Scan(&w.ID, &w.TenantID, &w.Name, &w.Description, &w.Trigger, &w.Status, &w.ExecutionCount, &w.LastExecutedAt, &w.CreatedAt, &w.UpdatedAt) + workflows = append(workflows, w) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"workflows": workflows}) +} + +// GetWorkflow hämtar en workflow +func (h *WorkflowHandler) GetWorkflow(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 workflow Workflow + err = h.db.QueryRow(` + SELECT id, tenant_id, name, description, trigger, trigger_config, conditions, actions, status, execution_count, last_executed_at, created_at, updated_at + FROM workflows WHERE id = $1 + `, id).Scan( + &workflow.ID, &workflow.TenantID, &workflow.Name, &workflow.Description, &workflow.Trigger, + &workflow.TriggerConfig, &workflow.Conditions, &workflow.Actions, &workflow.Status, + &workflow.ExecutionCount, &workflow.LastExecutedAt, &workflow.CreatedAt, &workflow.UpdatedAt, + ) + if err != nil { + writeError(w, http.StatusNotFound, "workflow not found") + return + } + + writeJSON(w, http.StatusOK, workflow) +} + +// UpdateWorkflow uppdaterar en workflow +func (h *WorkflowHandler) UpdateWorkflow(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 CreateWorkflowRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request") + return + } + + triggerConfig, _ := json.Marshal(req.TriggerConfig) + conditions, _ := json.Marshal(req.Conditions) + actions, _ := json.Marshal(req.Actions) + + _, err = h.db.Exec(` + UPDATE workflows + SET name = $1, description = $2, trigger = $3, trigger_config = $4, conditions = $5, actions = $6, updated_at = NOW() + WHERE id = $7 + `, req.Name, req.Description, req.Trigger, triggerConfig, conditions, actions, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "update failed") + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"updated": true}) +} + +// ToggleWorkflow aktiverar/pausar en workflow +func (h *WorkflowHandler) ToggleWorkflow(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"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request") + return + } + + _, err = h.db.Exec("UPDATE workflows SET status = $1, updated_at = NOW() WHERE id = $2", req.Status, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "update failed") + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"status": req.Status}) +} + +// ExecuteWorkflow manuellt kör en workflow +func (h *WorkflowHandler) ExecuteWorkflow(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 { + TriggerData map[string]interface{} `json:"trigger_data"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request") + return + } + + // Hämta workflow + var workflow Workflow + err = h.db.QueryRow(` + SELECT id, tenant_id, name, trigger, conditions, actions + FROM workflows WHERE id = $1 AND status = 'active' + `, id).Scan(&workflow.ID, &workflow.TenantID, &workflow.Name, &workflow.Trigger, &workflow.Conditions, &workflow.Actions) + if err != nil { + writeError(w, http.StatusNotFound, "workflow not found or not active") + return + } + + // Kör workflow + execution, err := h.executeWorkflow(workflow, req.TriggerData) + if err != nil { + writeError(w, http.StatusInternalServerError, "execution failed") + return + } + + writeJSON(w, http.StatusOK, execution) +} + +// ListExecutions listar workflow-körningar +func (h *WorkflowHandler) ListExecutions(w http.ResponseWriter, r *http.Request) { + workflowID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid workflow id") + return + } + + rows, err := h.db.Query(` + SELECT id, workflow_id, trigger_event, status, current_step, started_at, completed_at + FROM workflow_executions + WHERE workflow_id = $1 + ORDER BY started_at DESC + LIMIT 50 + `, workflowID) + if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + defer rows.Close() + + var executions []WorkflowExecution + for rows.Next() { + var e WorkflowExecution + rows.Scan(&e.ID, &e.WorkflowID, &e.TriggerEvent, &e.Status, &e.CurrentStep, &e.StartedAt, &e.CompletedAt) + executions = append(executions, e) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"executions": executions}) +} + +// executeWorkflow kör en workflow +func (h *WorkflowHandler) executeWorkflow(workflow Workflow, triggerData map[string]interface{}) (*WorkflowExecution, error) { + // Skapa execution record + triggerDataJSON, _ := json.Marshal(triggerData) + + var execution WorkflowExecution + err := h.db.QueryRow(` + INSERT INTO workflow_executions (workflow_id, trigger_event, trigger_data, status, current_step, steps) + VALUES ($1, $2, $3, 'running', 0, '[]') + RETURNING id, workflow_id, trigger_event, status, current_step, started_at + `, workflow.ID, workflow.Trigger, triggerDataJSON).Scan( + &execution.ID, &execution.WorkflowID, &execution.TriggerEvent, &execution.Status, &execution.CurrentStep, &execution.StartedAt, + ) + if err != nil { + return nil, err + } + + // Parsa actions + var actions []WorkflowAction + json.Unmarshal(workflow.Actions, &actions) + + // Kör varje action + steps := make([]map[string]interface{}, 0, len(actions)) + for i, action := range actions { + stepResult := map[string]interface{}{ + "step": i, + "action": action.Type, + "status": "completed", + } + + // Utför action + result, err := h.executeAction(action, triggerData) + if err != nil { + stepResult["status"] = "failed" + stepResult["error"] = err.Error() + + // Uppdatera execution med fel + h.db.Exec(` + UPDATE workflow_executions + SET status = 'failed', error = $1, steps = $2, completed_at = NOW() + WHERE id = $3 + `, err.Error(), mustMarshal(steps), execution.ID) + + return &execution, err + } + + stepResult["result"] = result + steps = append(steps, stepResult) + + // Uppdatera current step + h.db.Exec("UPDATE workflow_executions SET current_step = $1 WHERE id = $2", i+1, execution.ID) + } + + // Markera som completed + stepsJSON, _ := json.Marshal(steps) + h.db.Exec(` + UPDATE workflow_executions + SET status = 'completed', steps = $1, result = $2, completed_at = NOW() + WHERE id = $3 + `, stepsJSON, mustMarshal(triggerData), execution.ID) + + // Uppdatera workflow execution count + h.db.Exec(` + UPDATE workflows + SET execution_count = execution_count + 1, last_executed_at = NOW() + WHERE id = $1 + `, workflow.ID) + + execution.Status = "completed" + return &execution, nil +} + +// executeAction utför en enskild action +func (h *WorkflowHandler) executeAction(action WorkflowAction, context map[string]interface{}) (map[string]interface{}, error) { + switch action.Type { + case "create_contact": + return h.actionCreateContact(action.Config, context) + case "create_lead": + return h.actionCreateLead(action.Config, context) + case "update_lead": + return h.actionUpdateLead(action.Config, context) + case "add_tag": + return h.actionAddTag(action.Config, context) + case "assign_owner": + return h.actionAssignOwner(action.Config, context) + case "create_task": + return h.actionCreateTask(action.Config, context) + case "send_message": + return h.actionSendMessage(action.Config, context) + case "change_status": + return h.actionChangeStatus(action.Config, context) + case "escalate": + return h.actionEscalate(action.Config, context) + case "webhook": + return h.actionWebhook(action.Config, context) + case "wait": + // Wait är en no-op i synkron exekvering + return map[string]interface{}{"waited": true}, nil + default: + return nil, fmt.Errorf("unknown action type: %s", action.Type) + } +} + +// Action implementations +func (h *WorkflowHandler) actionCreateContact(config map[string]interface{}, context map[string]interface{}) (map[string]interface{}, error) { + // TODO: Implementera + return map[string]interface{}{"action": "create_contact", "status": "placeholder"}, nil +} + +func (h *WorkflowHandler) actionCreateLead(config map[string]interface{}, context map[string]interface{}) (map[string]interface{}, error) { + // TODO: Implementera + return map[string]interface{}{"action": "create_lead", "status": "placeholder"}, nil +} + +func (h *WorkflowHandler) actionUpdateLead(config map[string]interface{}, context map[string]interface{}) (map[string]interface{}, error) { + // TODO: Implementera + return map[string]interface{}{"action": "update_lead", "status": "placeholder"}, nil +} + +func (h *WorkflowHandler) actionAddTag(config map[string]interface{}, context map[string]interface{}) (map[string]interface{}, error) { + // TODO: Implementera + return map[string]interface{}{"action": "add_tag", "status": "placeholder"}, nil +} + +func (h *WorkflowHandler) actionAssignOwner(config map[string]interface{}, context map[string]interface{}) (map[string]interface{}, error) { + // TODO: Implementera + return map[string]interface{}{"action": "assign_owner", "status": "placeholder"}, nil +} + +func (h *WorkflowHandler) actionCreateTask(config map[string]interface{}, context map[string]interface{}) (map[string]interface{}, error) { + // TODO: Implementera + return map[string]interface{}{"action": "create_task", "status": "placeholder"}, nil +} + +func (h *WorkflowHandler) actionSendMessage(config map[string]interface{}, context map[string]interface{}) (map[string]interface{}, error) { + // TODO: Implementera + return map[string]interface{}{"action": "send_message", "status": "placeholder"}, nil +} + +func (h *WorkflowHandler) actionChangeStatus(config map[string]interface{}, context map[string]interface{}) (map[string]interface{}, error) { + // TODO: Implementera + return map[string]interface{}{"action": "change_status", "status": "placeholder"}, nil +} + +func (h *WorkflowHandler) actionEscalate(config map[string]interface{}, context map[string]interface{}) (map[string]interface{}, error) { + // TODO: Implementera + return map[string]interface{}{"action": "escalate", "status": "placeholder"}, nil +} + +func (h *WorkflowHandler) actionWebhook(config map[string]interface{}, context map[string]interface{}) (map[string]interface{}, error) { + // TODO: Implementera + return map[string]interface{}{"action": "webhook", "status": "placeholder"}, nil +} + +func mustMarshal(v interface{}) json.RawMessage { + b, _ := json.Marshal(v) + return b +} diff --git a/backend/main.go b/backend/main.go index 1d51f71a6..b97fed979 100644 --- a/backend/main.go +++ b/backend/main.go @@ -26,6 +26,7 @@ import ( "boc/db" "boc/employee" "boc/handlers" + "boc/handlers/comm" "boc/ledger" "boc/middleware" "boc/sms" @@ -407,6 +408,42 @@ func main() { r.Post("/api/v1/agents/chat", agentOrchestrator.HandleAgentChat) r.Get("/api/v1/agents/{id}", agentOrchestrator.HandleAgentStatus) r.Post("/api/v1/agents/{id}/execute", agentOrchestrator.HandleAgentChat) + + // Communication & Lead Layer + contactH := comm.NewContactHandler(database) + conversationH := comm.NewConversationHandler(database) + leadH := comm.NewLeadHandler(database) + workflowH := comm.NewWorkflowHandler(database) + + // Contacts + r.Post("/api/v1/comm/contacts", contactH.CreateContact) + r.Get("/api/v1/comm/contacts", contactH.ListContacts) + r.Get("/api/v1/comm/contacts/{id}", contactH.GetContact) + r.Post("/api/v1/comm/contacts/merge", contactH.MergeContacts) + + // Conversations + r.Post("/api/v1/comm/conversations", conversationH.CreateConversation) + r.Get("/api/v1/comm/conversations", conversationH.ListConversations) + r.Get("/api/v1/comm/conversations/{id}", conversationH.GetConversation) + r.Post("/api/v1/comm/conversations/{id}/messages", conversationH.SendMessage) + r.Put("/api/v1/comm/conversations/{id}/status", conversationH.UpdateStatus) + + // Leads + r.Post("/api/v1/comm/leads", leadH.CreateLead) + r.Get("/api/v1/comm/leads", leadH.ListLeads) + r.Get("/api/v1/comm/leads/{id}", leadH.GetLead) + r.Put("/api/v1/comm/leads/{id}", leadH.UpdateLead) + r.Put("/api/v1/comm/leads/{id}/qualification", leadH.UpdateQualification) + r.Get("/api/v1/comm/leads/{id}/score-history", leadH.GetLeadScoreHistory) + + // Workflows + r.Post("/api/v1/comm/workflows", workflowH.CreateWorkflow) + r.Get("/api/v1/comm/workflows", workflowH.ListWorkflows) + r.Get("/api/v1/comm/workflows/{id}", workflowH.GetWorkflow) + r.Put("/api/v1/comm/workflows/{id}", workflowH.UpdateWorkflow) + r.Post("/api/v1/comm/workflows/{id}/toggle", workflowH.ToggleWorkflow) + r.Post("/api/v1/comm/workflows/{id}/execute", workflowH.ExecuteWorkflow) + r.Get("/api/v1/comm/workflows/{id}/executions", workflowH.ListExecutions) }) // WebSocket (protected)