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 var nextActionAt *time.Time var nextActionType sql.NullString var tagsJSON []byte 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, 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, nullStringArray(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.LeadScore, &lead.QualificationComplete, &lead.LastInteractionAt, &nextActionAt, &nextActionType, &tagsJSON, &lead.CreatedAt, &lead.UpdatedAt, ) if nextActionAt != nil { lead.NextActionAt = nextActionAt } if nextActionType.Valid { lead.NextActionType = nextActionType.String } if len(tagsJSON) > 0 { json.Unmarshal(tagsJSON, &lead.Tags) } 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 }