feat(leads): add lead pipeline, keyword/status mapping, and encoding fixes
- Add lead ingestion, review queue, and keyword/status management - Add DB schema: leads, service_keywords, lead_statuses, processed_messages - Add migration with default keyword/status seeding per client - Fix SQLite read/write deadlock in sanitizeEncoding - Fix UTF-8 corruption: replace byte-iterating replaceAll with strings.ReplaceAll - Add utf8.ValidString guard to decodeLatin1 to avoid double-encoding - Remove hardcoded internal-secret; use config.InternalSecret() everywhere - Add .gitignore for binaries, SQLite DBs, build artifacts, WhatsApp sessions
This commit is contained in:
151
apps/go-crm/internal/handlers/lead_ingest.go
Normal file
151
apps/go-crm/internal/handlers/lead_ingest.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"go-crm/config"
|
||||
"go-crm/internal/db"
|
||||
"go-crm/internal/parser"
|
||||
)
|
||||
|
||||
// ingestRequest is the JSON body sent by the WhatsApp adapter.
|
||||
type ingestRequest struct {
|
||||
ClientID int64 `json:"client_id"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Message string `json:"message"`
|
||||
MessageID string `json:"message_id"`
|
||||
}
|
||||
|
||||
// IngestLead is the internal-only endpoint called by the WhatsApp adapter.
|
||||
// POST /leads/ingest (X-Internal-Secret required)
|
||||
func (a *App) IngestLead(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Internal-Secret") != config.InternalSecret() {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var req ingestRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.ClientID == 0 || req.Phone == "" {
|
||||
http.Error(w, "client_id and phone required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
phoneNorm := parser.NormalizePhone(req.Phone)
|
||||
|
||||
// Dedup: skip if this message was already processed.
|
||||
if req.MessageID != "" {
|
||||
processed, err := db.IsMessageProcessed(a.DB, req.ClientID, req.MessageID)
|
||||
if err != nil {
|
||||
log.Printf("IngestLead: db error checking processed message: %v", err)
|
||||
}
|
||||
if processed {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check if lead already exists for this phone.
|
||||
existing, err := db.GetLeadByPhone(a.DB, req.ClientID, phoneNorm)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
log.Printf("IngestLead: db error checking existing lead: %v", err)
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
// Known lead — just update last contact timestamp.
|
||||
if terr := db.TouchLeadContact(a.DB, existing.LeadID); terr != nil {
|
||||
log.Printf("IngestLead: failed to touch lead %d: %v", existing.LeadID, terr)
|
||||
}
|
||||
if req.MessageID != "" {
|
||||
if err := db.RecordProcessedMessage(a.DB, req.ClientID, req.MessageID); err != nil {
|
||||
log.Printf("IngestLead: failed to record processed message: %v", err)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// New lead — extract service from message text.
|
||||
mapping, err := db.LoadKeywordMapping(a.DB, req.ClientID)
|
||||
if err != nil {
|
||||
log.Printf("IngestLead: failed to load keyword mapping: %v", err)
|
||||
mapping = map[string]string{}
|
||||
}
|
||||
|
||||
serviceInterest := parser.ExtractService(req.Message, mapping)
|
||||
needsReview := serviceInterest == "Não especificou"
|
||||
|
||||
lead := &db.Lead{
|
||||
ClientID: req.ClientID,
|
||||
Name: req.Name,
|
||||
PhoneRaw: req.Phone,
|
||||
PhoneNormalized: phoneNorm,
|
||||
ServiceInterest: serviceInterest,
|
||||
Status: "Não agendou",
|
||||
NeedsReview: needsReview,
|
||||
PaymentStatus: "Não pago",
|
||||
}
|
||||
|
||||
if err := db.CreateLead(a.DB, lead); err != nil {
|
||||
log.Printf("IngestLead: failed to create lead: %v", err)
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if req.MessageID != "" {
|
||||
if err := db.RecordProcessedMessage(a.DB, req.ClientID, req.MessageID); err != nil {
|
||||
log.Printf("IngestLead: failed to record processed message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Seed default statuses/keywords for this client if first lead.
|
||||
go seedClientDefaults(a.DB, req.ClientID)
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
// seedClientDefaults seeds default statuses and keywords for a client on first lead.
|
||||
// It accepts the DB as a parameter so it can be called from both the App method
|
||||
// and the legacy package-level shim without touching global state.
|
||||
func seedClientDefaults(database *sql.DB, clientID int64) {
|
||||
statuses, _ := db.ListLeadStatuses(database, clientID)
|
||||
if len(statuses) == 0 {
|
||||
defaults := []string{"Não agendou", "Agendou", "Sem retorno / não evoluiu"}
|
||||
for _, s := range defaults {
|
||||
db.AddLeadStatus(database, clientID, s)
|
||||
}
|
||||
}
|
||||
|
||||
kws, _ := db.ListServiceKeywords(database, clientID)
|
||||
if len(kws) == 0 {
|
||||
defaultKWs := map[string][]string{
|
||||
"Head Spa": {"head spa", "head-spa", "headspa"},
|
||||
"Massagem completa": {"massagem", "massagem completa"},
|
||||
"Drenagem linfatica": {"drenagem", "linfática", "drenagem linfatica"},
|
||||
"Hydra Boost": {"hydra", "hydra boost"},
|
||||
"Design Henna": {"henna", "design henna"},
|
||||
"Masculino": {"masculino", "masc"},
|
||||
}
|
||||
for svc, keywords := range defaultKWs {
|
||||
for _, kw := range keywords {
|
||||
db.AddServiceKeyword(database, clientID, svc, kw)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- package-level shim kept for existing tests ---
|
||||
|
||||
func IngestLead(w http.ResponseWriter, r *http.Request) {
|
||||
(&App{DB: DB, WAConnector: WAConnector}).IngestLead(w, r)
|
||||
}
|
||||
Reference in New Issue
Block a user