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:
Binary file not shown.
Binary file not shown.
@@ -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);
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"boc/db"
|
"boc/db"
|
||||||
"boc/employee"
|
"boc/employee"
|
||||||
"boc/handlers"
|
"boc/handlers"
|
||||||
|
"boc/handlers/comm"
|
||||||
"boc/ledger"
|
"boc/ledger"
|
||||||
"boc/middleware"
|
"boc/middleware"
|
||||||
"boc/sms"
|
"boc/sms"
|
||||||
@@ -407,6 +408,42 @@ func main() {
|
|||||||
r.Post("/api/v1/agents/chat", agentOrchestrator.HandleAgentChat)
|
r.Post("/api/v1/agents/chat", agentOrchestrator.HandleAgentChat)
|
||||||
r.Get("/api/v1/agents/{id}", agentOrchestrator.HandleAgentStatus)
|
r.Get("/api/v1/agents/{id}", agentOrchestrator.HandleAgentStatus)
|
||||||
r.Post("/api/v1/agents/{id}/execute", agentOrchestrator.HandleAgentChat)
|
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)
|
// WebSocket (protected)
|
||||||
|
|||||||
Reference in New Issue
Block a user