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:
2026-05-23 16:49:43 -03:00
parent 57e6e5a6fd
commit 57920d45d6
7 changed files with 1437 additions and 42 deletions

View 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)
}