- 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
152 lines
4.5 KiB
Go
152 lines
4.5 KiB
Go
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)
|
|
}
|