feat(comm): implement Communication & Lead Layer core
- Add database migration: contacts, conversations, messages, leads, workflows, follow-ups, audit log - Contact handler with deduplication and identity resolution - Conversation handler with unified inbox - Lead handler with scoring engine and qualification - Workflow handler with trigger-action engine - Full API routes for all comm layer endpoints - 12 operational agents integrated in all BOC modules
This commit is contained in:
@@ -0,0 +1,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
|
||||
}
|
||||
Reference in New Issue
Block a user