From 57920d45d6d9fdd29c8ea4ee8c52984c66b8ebed Mon Sep 17 00:00:00 2001 From: gabspereira Date: Sat, 23 May 2026 16:49:43 -0300 Subject: [PATCH] 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 --- apps/go-crm/.gitignore | 45 ++ apps/go-crm/internal/db/crud.go | 318 ++++++++++++- apps/go-crm/internal/db/db.go | 215 ++++++++- apps/go-crm/internal/handlers/customers.go | 85 ++-- apps/go-crm/internal/handlers/lead_ingest.go | 151 +++++++ .../go-crm/internal/handlers/lead_keywords.go | 242 ++++++++++ apps/go-crm/internal/handlers/lead_review.go | 423 ++++++++++++++++++ 7 files changed, 1437 insertions(+), 42 deletions(-) create mode 100644 apps/go-crm/.gitignore create mode 100644 apps/go-crm/internal/handlers/lead_ingest.go create mode 100644 apps/go-crm/internal/handlers/lead_keywords.go create mode 100644 apps/go-crm/internal/handlers/lead_review.go diff --git a/apps/go-crm/.gitignore b/apps/go-crm/.gitignore new file mode 100644 index 0000000..f1b0625 --- /dev/null +++ b/apps/go-crm/.gitignore @@ -0,0 +1,45 @@ +# Binaries +/go-crm +/main +/migrate +/go-crm-test +*.exe +*.dll +*.so +*.dylib + +# Build artifacts +tmp/ +*.out +*.log +build-errors.* + +# SQLite databases +data/ +.dev-data/ +*.db +*.db-journal +*.db-shm +*.db-wal +*.db.bak + +# Test output +*.test +*.cover +*.cov +coverage/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# WhatsApp session files (if ever synced here) +.wwebjs_auth/ +.wwebjs_cache/ diff --git a/apps/go-crm/internal/db/crud.go b/apps/go-crm/internal/db/crud.go index e156836..18ceba9 100644 --- a/apps/go-crm/internal/db/crud.go +++ b/apps/go-crm/internal/db/crud.go @@ -2,6 +2,7 @@ package db import ( "database/sql" + "time" ) type Client struct { @@ -19,13 +20,15 @@ type Client struct { } type Customer struct { - CustomerID int64 `json:"customer_id"` - ClientID int64 `json:"client_id"` - Name string `json:"name"` - Phone string `json:"phone,omitempty"` - BirthDate string `json:"birth_date,omitempty"` - Instagram string `json:"instagram,omitempty"` - CreatedAt int64 `json:"created_at"` + CustomerID int64 `json:"customer_id"` + ClientID int64 `json:"client_id"` + Name string `json:"name"` + Phone string `json:"phone,omitempty"` + BirthDate string `json:"birth_date,omitempty"` + Instagram string `json:"instagram,omitempty"` + CreatedAt int64 `json:"created_at"` + WhatsAppConnected int `json:"whatsapp_connected"` + WhatsAppNumber string `json:"whatsapp_number,omitempty"` } type Service struct { @@ -343,6 +346,283 @@ func boolToInt(b bool) int { return 0 } +// --------------------------------------------------------------------------- +// Lead +// --------------------------------------------------------------------------- + +type Lead struct { + LeadID int64 `json:"lead_id"` + ClientID int64 `json:"client_id"` + Name string `json:"name"` + PhoneRaw string `json:"phone_raw"` + PhoneNormalized string `json:"phone_normalized"` + ServiceInterest string `json:"service_interest"` + Status string `json:"status"` + NeedsReview bool `json:"needs_review"` + AppointmentDate string `json:"appointment_date,omitempty"` + AppointmentTime string `json:"appointment_time,omitempty"` + PaymentStatus string `json:"payment_status"` + PaymentAmount float64 `json:"payment_amount,omitempty"` + PaymentDate string `json:"payment_date,omitempty"` + CreatedAt int64 `json:"created_at"` + LastContactAt int64 `json:"last_contact_at"` +} + +func CreateLead(db *sql.DB, l *Lead) error { + now := time.Now().Unix() + l.CreatedAt = now + l.LastContactAt = now + result, err := db.Exec( + `INSERT INTO leads + (client_id, name, phone_raw, phone_normalized, service_interest, status, needs_review, + appointment_date, appointment_time, payment_status, payment_amount, payment_date, created_at, last_contact_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + l.ClientID, l.Name, l.PhoneRaw, l.PhoneNormalized, l.ServiceInterest, l.Status, boolToInt(l.NeedsReview), + l.AppointmentDate, l.AppointmentTime, l.PaymentStatus, l.PaymentAmount, l.PaymentDate, + l.CreatedAt, l.LastContactAt, + ) + if err != nil { + return err + } + id, err := result.LastInsertId() + if err != nil { + return err + } + l.LeadID = id + return nil +} + +func GetLeadByPhone(db *sql.DB, clientID int64, phoneNormalized string) (*Lead, error) { + var l Lead + var needsReview int + err := db.QueryRow( + `SELECT lead_id, client_id, name, phone_raw, phone_normalized, service_interest, status, needs_review, + COALESCE(appointment_date,''), COALESCE(appointment_time,''), payment_status, + COALESCE(payment_amount,0), COALESCE(payment_date,''), created_at, last_contact_at + FROM leads WHERE client_id = ? AND phone_normalized = ?`, + clientID, phoneNormalized, + ).Scan( + &l.LeadID, &l.ClientID, &l.Name, &l.PhoneRaw, &l.PhoneNormalized, + &l.ServiceInterest, &l.Status, &needsReview, + &l.AppointmentDate, &l.AppointmentTime, &l.PaymentStatus, + &l.PaymentAmount, &l.PaymentDate, &l.CreatedAt, &l.LastContactAt, + ) + if err != nil { + return nil, err + } + l.NeedsReview = needsReview == 1 + return &l, nil +} + +func GetLeadByID(db *sql.DB, clientID, leadID int64) (*Lead, error) { + var l Lead + var needsReview int + err := db.QueryRow( + `SELECT lead_id, client_id, name, phone_raw, phone_normalized, service_interest, status, needs_review, + COALESCE(appointment_date,''), COALESCE(appointment_time,''), payment_status, + COALESCE(payment_amount,0), COALESCE(payment_date,''), created_at, last_contact_at + FROM leads WHERE lead_id = ? AND client_id = ?`, + leadID, clientID, + ).Scan( + &l.LeadID, &l.ClientID, &l.Name, &l.PhoneRaw, &l.PhoneNormalized, + &l.ServiceInterest, &l.Status, &needsReview, + &l.AppointmentDate, &l.AppointmentTime, &l.PaymentStatus, + &l.PaymentAmount, &l.PaymentDate, &l.CreatedAt, &l.LastContactAt, + ) + if err != nil { + return nil, err + } + l.NeedsReview = needsReview == 1 + return &l, nil +} + +func TouchLeadContact(db *sql.DB, leadID int64) error { + _, err := db.Exec( + "UPDATE leads SET last_contact_at = ? WHERE lead_id = ?", + time.Now().Unix(), leadID, + ) + return err +} + +func UpdateLead(db *sql.DB, l *Lead) error { + _, err := db.Exec( + `UPDATE leads SET name=?, service_interest=?, status=?, needs_review=?, + appointment_date=?, appointment_time=?, payment_status=?, payment_amount=?, payment_date=? + WHERE lead_id=? AND client_id=?`, + l.Name, l.ServiceInterest, l.Status, boolToInt(l.NeedsReview), + l.AppointmentDate, l.AppointmentTime, l.PaymentStatus, l.PaymentAmount, l.PaymentDate, + l.LeadID, l.ClientID, + ) + return err +} + +func ListLeadsForReview(db *sql.DB, clientID int64, limit, offset int) ([]Lead, error) { + rows, err := db.Query( + `SELECT lead_id, client_id, name, phone_raw, phone_normalized, service_interest, status, needs_review, + COALESCE(appointment_date,''), COALESCE(appointment_time,''), payment_status, + COALESCE(payment_amount,0), COALESCE(payment_date,''), created_at, last_contact_at + FROM leads WHERE client_id = ? AND needs_review = 1 + ORDER BY created_at DESC LIMIT ? OFFSET ?`, + clientID, limit, offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return scanLeads(rows) +} + +func ListAllLeads(db *sql.DB, clientID int64, limit, offset int) ([]Lead, error) { + rows, err := db.Query( + `SELECT lead_id, client_id, name, phone_raw, phone_normalized, service_interest, status, needs_review, + COALESCE(appointment_date,''), COALESCE(appointment_time,''), payment_status, + COALESCE(payment_amount,0), COALESCE(payment_date,''), created_at, last_contact_at + FROM leads WHERE client_id = ? + ORDER BY created_at DESC LIMIT ? OFFSET ?`, + clientID, limit, offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return scanLeads(rows) +} + +func CountLeadsNeedingReview(db *sql.DB, clientID int64) (int, error) { + var n int + err := db.QueryRow( + "SELECT COUNT(*) FROM leads WHERE client_id = ? AND needs_review = 1", + clientID, + ).Scan(&n) + return n, err +} + +func scanLeads(rows *sql.Rows) ([]Lead, error) { + var leads []Lead + for rows.Next() { + var l Lead + var needsReview int + if err := rows.Scan( + &l.LeadID, &l.ClientID, &l.Name, &l.PhoneRaw, &l.PhoneNormalized, + &l.ServiceInterest, &l.Status, &needsReview, + &l.AppointmentDate, &l.AppointmentTime, &l.PaymentStatus, + &l.PaymentAmount, &l.PaymentDate, &l.CreatedAt, &l.LastContactAt, + ); err != nil { + return nil, err + } + l.NeedsReview = needsReview == 1 + leads = append(leads, l) + } + return leads, rows.Err() +} + +// --------------------------------------------------------------------------- +// ServiceKeyword +// --------------------------------------------------------------------------- + +type ServiceKeyword struct { + KeywordID int64 `json:"keyword_id"` + ClientID int64 `json:"client_id"` + ServiceName string `json:"service_name"` + Keyword string `json:"keyword"` + CreatedAt int64 `json:"created_at"` +} + +func ListServiceKeywords(db *sql.DB, clientID int64) ([]ServiceKeyword, error) { + rows, err := db.Query( + "SELECT keyword_id, client_id, service_name, keyword, created_at FROM service_keywords WHERE client_id = ? ORDER BY service_name, keyword", + clientID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var kws []ServiceKeyword + for rows.Next() { + var kw ServiceKeyword + if err := rows.Scan(&kw.KeywordID, &kw.ClientID, &kw.ServiceName, &kw.Keyword, &kw.CreatedAt); err != nil { + return nil, err + } + kws = append(kws, kw) + } + return kws, rows.Err() +} + +func LoadKeywordMapping(db *sql.DB, clientID int64) (map[string]string, error) { + kws, err := ListServiceKeywords(db, clientID) + if err != nil { + return nil, err + } + m := make(map[string]string, len(kws)) + for _, kw := range kws { + m[kw.Keyword] = kw.ServiceName + } + return m, nil +} + +func AddServiceKeyword(db *sql.DB, clientID int64, serviceName, keyword string) error { + _, err := db.Exec( + "INSERT INTO service_keywords (client_id, service_name, keyword, created_at) VALUES (?, ?, ?, ?)", + clientID, serviceName, keyword, time.Now().Unix(), + ) + return err +} + +func DeleteServiceKeyword(db *sql.DB, clientID, keywordID int64) error { + _, err := db.Exec( + "DELETE FROM service_keywords WHERE keyword_id = ? AND client_id = ?", + keywordID, clientID, + ) + return err +} + +// --------------------------------------------------------------------------- +// LeadStatus +// --------------------------------------------------------------------------- + +type LeadStatus struct { + StatusID int64 `json:"status_id"` + ClientID int64 `json:"client_id"` + StatusName string `json:"status_name"` + CreatedAt int64 `json:"created_at"` +} + +func ListLeadStatuses(db *sql.DB, clientID int64) ([]LeadStatus, error) { + rows, err := db.Query( + "SELECT status_id, client_id, status_name, created_at FROM lead_statuses WHERE client_id = ? ORDER BY created_at", + clientID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var statuses []LeadStatus + for rows.Next() { + var s LeadStatus + if err := rows.Scan(&s.StatusID, &s.ClientID, &s.StatusName, &s.CreatedAt); err != nil { + return nil, err + } + statuses = append(statuses, s) + } + return statuses, rows.Err() +} + +func AddLeadStatus(db *sql.DB, clientID int64, statusName string) error { + _, err := db.Exec( + "INSERT INTO lead_statuses (client_id, status_name, created_at) VALUES (?, ?, ?)", + clientID, statusName, time.Now().Unix(), + ) + return err +} + +func DeleteLeadStatus(db *sql.DB, clientID, statusID int64) error { + _, err := db.Exec( + "DELETE FROM lead_statuses WHERE status_id = ? AND client_id = ?", + statusID, clientID, + ) + return err +} + type Question struct { QuestionID int64 `json:"question_id"` ClientID int64 `json:"client_id"` @@ -497,4 +777,28 @@ func ListAnswersWithDetails(db *sql.DB, accountID, questionID int64, limit, offs answers = append(answers, a) } return answers, rows.Err() +} + +// --------------------------------------------------------------------------- +// Processed Messages (dedup) +// --------------------------------------------------------------------------- + +func IsMessageProcessed(db *sql.DB, clientID int64, messageID string) (bool, error) { + var count int + err := db.QueryRow( + `SELECT COUNT(*) FROM processed_messages WHERE client_id = ? AND message_id = ?`, + clientID, messageID, + ).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +func RecordProcessedMessage(db *sql.DB, clientID int64, messageID string) error { + _, err := db.Exec( + `INSERT OR IGNORE INTO processed_messages (message_id, client_id) VALUES (?, ?)`, + messageID, clientID, + ) + return err } \ No newline at end of file diff --git a/apps/go-crm/internal/db/db.go b/apps/go-crm/internal/db/db.go index b1c0fa4..08e78c3 100644 --- a/apps/go-crm/internal/db/db.go +++ b/apps/go-crm/internal/db/db.go @@ -3,9 +3,11 @@ package db import ( "database/sql" "fmt" + "golang.org/x/text/encoding/charmap" "os" "path/filepath" "strings" + "unicode/utf8" _ "github.com/glebarez/sqlite" ) @@ -116,6 +118,47 @@ CREATE TABLE IF NOT EXISTS answers ( FOREIGN KEY (question_id) REFERENCES questions(question_id) ); +CREATE TABLE IF NOT EXISTS leads ( + lead_id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL, + name TEXT NOT NULL DEFAULT '', + phone_raw TEXT NOT NULL DEFAULT '', + phone_normalized TEXT NOT NULL DEFAULT '', + service_interest TEXT NOT NULL DEFAULT 'Não especificou', + status TEXT NOT NULL DEFAULT 'Não agendou', + needs_review INTEGER NOT NULL DEFAULT 1, + appointment_date TEXT, + appointment_time TEXT, + payment_status TEXT NOT NULL DEFAULT 'Não pago', + payment_amount REAL, + payment_date TEXT, + created_at INTEGER NOT NULL, + last_contact_at INTEGER NOT NULL, + FOREIGN KEY (client_id) REFERENCES clients(client_id) +); + +CREATE TABLE IF NOT EXISTS processed_messages ( + message_id TEXT NOT NULL, + client_id INTEGER NOT NULL, + PRIMARY KEY (message_id, client_id) +); + +CREATE TABLE IF NOT EXISTS service_keywords ( + keyword_id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL, + service_name TEXT NOT NULL, + keyword TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (client_id) REFERENCES clients(client_id) +); + +CREATE TABLE IF NOT EXISTS lead_statuses ( + status_id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL, + status_name TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (client_id) REFERENCES clients(client_id) +); ` @@ -127,31 +170,39 @@ func Init(path string) (*sql.DB, error) { } dir := filepath.Dir(dbPath) + fmt.Println("db.Init: data dir", dir) if err := os.MkdirAll(dir, 0755); err != nil { return nil, fmt.Errorf("failed to create data directory: %w", err) } - database, err := sql.Open("sqlite", dbPath) + dsn := fmt.Sprintf("file:%s?mode=rwc&_pragma=encoding(UTF8)", filepath.ToSlash(dbPath)) + fmt.Println("db.Init: opening database", dbPath) + database, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } + fmt.Println("db.Init: ping database") if err := database.Ping(); err != nil { return nil, fmt.Errorf("failed to ping database: %w", err) } + fmt.Println("db.Init: executing schema") if _, err := database.Exec(schema); err != nil { return nil, fmt.Errorf("failed to create schema: %w", err) } + fmt.Println("db.Init: running migrate") if err := migrate(database); err != nil { return nil, fmt.Errorf("failed to migrate: %w", err) } + fmt.Println("db.Init: migration complete") return database, nil } func migrate(db *sql.DB) error { + fmt.Println("db.migrate: add account_id column") _, err := db.Exec("ALTER TABLE clients ADD COLUMN account_id INTEGER") if err != nil { if !strings.Contains(err.Error(), "duplicate column name") { @@ -159,6 +210,7 @@ func migrate(db *sql.DB) error { } } + fmt.Println("db.migrate: add whatsapp_number column") _, err = db.Exec("ALTER TABLE clients ADD COLUMN whatsapp_number TEXT") if err != nil { if !strings.Contains(err.Error(), "duplicate column name") { @@ -166,6 +218,7 @@ func migrate(db *sql.DB) error { } } + fmt.Println("db.migrate: add whatsapp_connected column") _, err = db.Exec("ALTER TABLE clients ADD COLUMN whatsapp_connected INTEGER DEFAULT 0") if err != nil { if !strings.Contains(err.Error(), "duplicate column name") { @@ -173,6 +226,7 @@ func migrate(db *sql.DB) error { } } + fmt.Println("db.migrate: add whatsapp_jid column") _, err = db.Exec("ALTER TABLE clients ADD COLUMN whatsapp_jid TEXT") if err != nil { if !strings.Contains(err.Error(), "duplicate column name") { @@ -180,5 +234,162 @@ func migrate(db *sql.DB) error { } } + fmt.Println("db.migrate: seeding default keywords") + // Seed default service keywords per client if none exist yet. + if err := seedDefaultKeywords(db); err != nil { + return err + } + + fmt.Println("db.migrate: seeding default statuses") + // Seed default lead statuses per client if none exist yet. + if err := seedDefaultStatuses(db); err != nil { + return err + } + + fmt.Println("db.migrate: sanitizing encodings") + // Sanitize mojibake from legacy data: decode Latin1->UTF8 + if err := sanitizeEncoding(db); err != nil { + return err + } + return nil -} \ No newline at end of file +} + +func seedDefaultKeywords(db *sql.DB) error { + fmt.Println("seedDefaultKeywords: querying clients") + // Fetch all client IDs. + rows, err := db.Query("SELECT client_id FROM clients") + if err != nil { + return nil // table may not exist yet on very first run — ignore + } + defer rows.Close() + + defaults := map[string][]string{ + "Head Spa": {"head spa", "head-spa", "headspa"}, + "Massagem completa": {"massagem", "massagem completa"}, + "Drenagem linfatica": {"drenagem", "linfática", "linfahtica", "drenagem linfatica"}, + "Hydra Boost": {"hydra", "hydra boost", "hydraboost"}, + "Design Henna": {"henna", "design henna"}, + "Masculino": {"masculino", "masc"}, + "Não especificou": {"nao especificou", "não especificou"}, + } + + now := int64(0) + for rows.Next() { + var clientID int64 + if err := rows.Scan(&clientID); err != nil { + continue + } + fmt.Println("seedDefaultKeywords: client", clientID) + var count int + db.QueryRow("SELECT COUNT(*) FROM service_keywords WHERE client_id = ?", clientID).Scan(&count) + if count > 0 { + continue + } + for svc, keywords := range defaults { + for _, kw := range keywords { + db.Exec( + "INSERT INTO service_keywords (client_id, service_name, keyword, created_at) VALUES (?, ?, ?, ?)", + clientID, svc, kw, now, + ) + } + } + } + return nil +} + +func seedDefaultStatuses(db *sql.DB) error { + rows, err := db.Query("SELECT client_id FROM clients") + if err != nil { + return nil + } + defer rows.Close() + + defaults := []string{"Não agendou", "Agendou", "Sem retorno / não evoluiu"} + now := int64(0) + + for rows.Next() { + var clientID int64 + if err := rows.Scan(&clientID); err != nil { + continue + } + var count int + db.QueryRow("SELECT COUNT(*) FROM lead_statuses WHERE client_id = ?", clientID).Scan(&count) + if count > 0 { + continue + } + for _, s := range defaults { + db.Exec( + "INSERT INTO lead_statuses (client_id, status_name, created_at) VALUES (?, ?, ?)", + clientID, s, now, + ) + } + } + return nil +} + +func sanitizeEncoding(db *sql.DB) error { + type statusRow struct { + id int64 + name string + created int64 + } + type keywordRow struct { + id int64 + keyword string + created int64 + } + + // Read all statuses first, then update to avoid SQLite read/write deadlock. + var statuses []statusRow + if rows, err := db.Query("SELECT status_id, status_name, created_at FROM lead_statuses"); err == nil { + for rows.Next() { + var r statusRow + if err := rows.Scan(&r.id, &r.name, &r.created); err == nil && r.created != 0 { + statuses = append(statuses, r) + } + } + rows.Close() + } + for _, r := range statuses { + cleaned := decodeLatin1(r.name) + if cleaned != r.name { + db.Exec("UPDATE lead_statuses SET status_name = ? WHERE status_id = ?", cleaned, r.id) + } + } + + // Read all keywords first, then update. + var keywords []keywordRow + if rows2, err := db.Query("SELECT keyword_id, keyword, created_at FROM service_keywords"); err == nil { + for rows2.Next() { + var r keywordRow + if err := rows2.Scan(&r.id, &r.keyword, &r.created); err == nil && r.created != 0 { + keywords = append(keywords, r) + } + } + rows2.Close() + } + for _, r := range keywords { + cleaned := decodeLatin1(r.keyword) + if cleaned != r.keyword { + db.Exec("UPDATE service_keywords SET keyword = ? WHERE keyword_id = ?", cleaned, r.id) + } + } + + return nil +} + +func decodeLatin1(value string) string { + if utf8.ValidString(value) { + return value + } + dec := charmap.ISO8859_1.NewDecoder() + cleaned := value + for { + next, err := dec.String(cleaned) + if err != nil || next == cleaned { + return cleaned + } + cleaned = next + } +} diff --git a/apps/go-crm/internal/handlers/customers.go b/apps/go-crm/internal/handlers/customers.go index d4750f8..d91725f 100644 --- a/apps/go-crm/internal/handlers/customers.go +++ b/apps/go-crm/internal/handlers/customers.go @@ -5,13 +5,14 @@ import ( "strconv" "time" + "go-crm/config" "go-crm/internal/db" "github.com/go-chi/chi/v5" ) -func ListCustomers(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) +func (a *App) ListCustomers(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) if !ok { return } @@ -23,13 +24,13 @@ func ListCustomers(w http.ResponseWriter, r *http.Request) { limit = 20 } - customers, err := db.ListCustomers(DB, accountID, clientID, limit, offset) + customers, err := db.ListCustomers(a.DB, accountID, clientID, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - clients, err := db.ListClients(DB, accountID, limit, offset) + clients, err := db.ListClients(a.DB, accountID, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -40,7 +41,7 @@ func ListCustomers(w http.ResponseWriter, r *http.Request) { clientOptions += `` } - w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(`

Customers

`)) for _, c := range customers { w.Write([]byte(``)) @@ -48,24 +49,24 @@ func ListCustomers(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`
NamePhoneBirth DateInstagramActions
` + c.Name + `` + c.Phone + `` + c.BirthDate + `` + c.Instagram + `View
`)) } -func CreateCustomer(w http.ResponseWriter, r *http.Request) { - isInternal := r.Header.Get("X-Internal-Secret") == "internal-secret" - +func (a *App) CreateCustomer(w http.ResponseWriter, r *http.Request) { + isInternal := r.Header.Get("X-Internal-Secret") == config.InternalSecret() + var accountID int64 var clientID int64 var ok bool - + if isInternal { clientID, _ = strconv.ParseInt(r.FormValue("client_id"), 10, 64) accountID = clientID ok = true } else { - accountID, ok = requireAuth(w, r) + accountID, ok = a.requireAuth(w, r) if ok { clientID, _ = strconv.ParseInt(r.FormValue("client_id"), 10, 64) } } - + if !ok { return } @@ -74,10 +75,10 @@ func CreateCustomer(w http.ResponseWriter, r *http.Request) { if clientID == 0 { clientID, _ = strconv.ParseInt(r.FormValue("client_id"), 10, 64) } - + if !isInternal { var checkID int64 - err := DB.QueryRow("SELECT client_id FROM clients WHERE client_id = ? AND account_id = ?", clientID, accountID).Scan(&checkID) + err := a.DB.QueryRow("SELECT client_id FROM clients WHERE client_id = ? AND account_id = ?", clientID, accountID).Scan(&checkID) if err != nil { http.Error(w, "Invalid client", http.StatusBadRequest) return @@ -85,22 +86,22 @@ func CreateCustomer(w http.ResponseWriter, r *http.Request) { } customer := db.Customer{ - ClientID: clientID, - Name: r.FormValue("name"), - Phone: r.FormValue("phone"), - BirthDate: r.FormValue("birth_date"), - Instagram: r.FormValue("instagram"), - CreatedAt: time.Now().Unix(), + ClientID: clientID, + Name: r.FormValue("name"), + Phone: r.FormValue("phone"), + BirthDate: r.FormValue("birth_date"), + Instagram: r.FormValue("instagram"), + CreatedAt: time.Now().Unix(), } if phone := r.FormValue("phone"); phone != "" { var existingID int64 - err := DB.QueryRow( + err := a.DB.QueryRow( "SELECT customer_id FROM customers WHERE client_id = ? AND phone = ?", clientID, phone, ).Scan(&existingID) if err == nil { - _, err = DB.Exec( + _, err = a.DB.Exec( "UPDATE customers SET name = ?, birth_date = ?, instagram = ? WHERE customer_id = ?", r.FormValue("name"), r.FormValue("birth_date"), r.FormValue("instagram"), existingID, ) @@ -111,7 +112,7 @@ func CreateCustomer(w http.ResponseWriter, r *http.Request) { } } - if err := customer.Create(DB); err != nil { + if err := customer.Create(a.DB); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -119,26 +120,26 @@ func CreateCustomer(w http.ResponseWriter, r *http.Request) { w.Header().Set("HX-Refresh", "true") } -func ViewCustomer(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) ViewCustomer(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) var c db.Customer - err := DB.QueryRow("SELECT customer_id, client_id, name, phone, birth_date, instagram, created_at FROM customers WHERE customer_id = ?", id).Scan(&c.CustomerID, &c.ClientID, &c.Name, &c.Phone, &c.BirthDate, &c.Instagram, &c.CreatedAt) + err := a.DB.QueryRow("SELECT customer_id, client_id, name, phone, birth_date, instagram, created_at FROM customers WHERE customer_id = ?", id).Scan(&c.CustomerID, &c.ClientID, &c.Name, &c.Phone, &c.BirthDate, &c.Instagram, &c.CreatedAt) if err != nil { http.Error(w, "Customer not found", http.StatusNotFound) return } - w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(`

` + c.Name + `

Phone: ` + c.Phone + `

Birth Date: ` + c.BirthDate + `

Instagram: ` + c.Instagram + `

Back`)) } -func UpdateCustomer(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) UpdateCustomer(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } @@ -146,7 +147,7 @@ func UpdateCustomer(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) r.ParseForm() clientID, _ := strconv.ParseInt(r.FormValue("client_id"), 10, 64) - _, err := DB.Exec("UPDATE customers SET client_id=?, name=?, phone=?, birth_date=?, instagram=? WHERE customer_id=?", clientID, r.FormValue("name"), r.FormValue("phone"), r.FormValue("birth_date"), r.FormValue("instagram"), id) + _, err := a.DB.Exec("UPDATE customers SET client_id=?, name=?, phone=?, birth_date=?, instagram=? WHERE customer_id=?", clientID, r.FormValue("name"), r.FormValue("phone"), r.FormValue("birth_date"), r.FormValue("instagram"), id) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -154,12 +155,30 @@ func UpdateCustomer(w http.ResponseWriter, r *http.Request) { w.Header().Set("HX-Refresh", "true") } -func DeleteCustomer(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) DeleteCustomer(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) - DB.Exec("DELETE FROM customers WHERE customer_id = ?", id) -} \ No newline at end of file + a.DB.Exec("DELETE FROM customers WHERE customer_id = ?", id) +} + +// --- package-level shims kept for existing tests --- + +func ListCustomers(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).ListCustomers(w, r) +} +func CreateCustomer(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).CreateCustomer(w, r) +} +func ViewCustomer(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).ViewCustomer(w, r) +} +func UpdateCustomer(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).UpdateCustomer(w, r) +} +func DeleteCustomer(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).DeleteCustomer(w, r) +} diff --git a/apps/go-crm/internal/handlers/lead_ingest.go b/apps/go-crm/internal/handlers/lead_ingest.go new file mode 100644 index 0000000..ff325f8 --- /dev/null +++ b/apps/go-crm/internal/handlers/lead_ingest.go @@ -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) +} diff --git a/apps/go-crm/internal/handlers/lead_keywords.go b/apps/go-crm/internal/handlers/lead_keywords.go new file mode 100644 index 0000000..46166e7 --- /dev/null +++ b/apps/go-crm/internal/handlers/lead_keywords.go @@ -0,0 +1,242 @@ +package handlers + +import ( + "fmt" + "net/http" + "strconv" + + "go-crm/internal/db" + + "github.com/go-chi/chi/v5" +) + +// LeadKeywordsPage renders the keyword mapping management screen. +// GET /leads/keywords +func (a *App) LeadKeywordsPage(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) + if !ok { + return + } + + clientID := a.clientIDForAccount(accountID) + if clientID == 0 { + http.Error(w, "No client found for account", http.StatusBadRequest) + return + } + + kws, err := db.ListServiceKeywords(a.DB, clientID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + statuses, _ := db.ListLeadStatuses(a.DB, clientID) + services := a.serviceNames(clientID) + + pendingCount, _ := db.CountLeadsNeedingReview(a.DB, clientID) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, ` + + + + Keyword Mapping + + + + +

Keyword Mapping

+ +
+ +

Service Keywords

+

Add keywords that trigger automatic service detection. Case and accent insensitive.

+
+ + + +
+

+ %s + +
+

Lead Statuses

+

Manage the status options available for leads.

+
+ + +
+

+ %s +
+`, + pendingCount, + buildServiceSelectOptions(services), + renderKeywordTable(kws), + renderStatusTable(statuses), + ) +} + +// AddServiceKeyword adds a new keyword mapping. +// POST /leads/keywords +func (a *App) AddServiceKeyword(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) + if !ok { + return + } + clientID := a.clientIDForAccount(accountID) + r.ParseForm() + svc := r.FormValue("service_name") + kw := r.FormValue("keyword") + if svc == "" || kw == "" { + http.Error(w, "service_name and keyword required", http.StatusBadRequest) + return + } + db.AddServiceKeyword(a.DB, clientID, svc, kw) + + kws, _ := db.ListServiceKeywords(a.DB, clientID) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, renderKeywordTable(kws)) +} + +// DeleteServiceKeywordHandler removes a keyword mapping. +// DELETE /leads/keywords/{id} +func (a *App) DeleteServiceKeywordHandler(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) + if !ok { + return + } + clientID := a.clientIDForAccount(accountID) + kwID, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + db.DeleteServiceKeyword(a.DB, clientID, kwID) + + kws, _ := db.ListServiceKeywords(a.DB, clientID) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, renderKeywordTable(kws)) +} + +// AddLeadStatusHandler adds a new custom lead status. +// POST /leads/statuses +func (a *App) AddLeadStatusHandler(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) + if !ok { + return + } + clientID := a.clientIDForAccount(accountID) + r.ParseForm() + statusName := r.FormValue("status_name") + if statusName == "" { + http.Error(w, "status_name required", http.StatusBadRequest) + return + } + db.AddLeadStatus(a.DB, clientID, statusName) + + statuses, _ := db.ListLeadStatuses(a.DB, clientID) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, renderStatusTable(statuses)) +} + +// DeleteLeadStatusHandler removes a lead status. +// DELETE /leads/statuses/{id} +func (a *App) DeleteLeadStatusHandler(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) + if !ok { + return + } + clientID := a.clientIDForAccount(accountID) + statusID, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + db.DeleteLeadStatus(a.DB, clientID, statusID) + + statuses, _ := db.ListLeadStatuses(a.DB, clientID) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, renderStatusTable(statuses)) +} + +// --- rendering helpers ------------------------------------------------------- + +func buildServiceSelectOptions(services []string) string { + out := "" + for _, s := range services { + if s == "Não especificou" { + continue // don't map keywords to the fallback + } + out += fmt.Sprintf(``, htmlEscape(s), htmlEscape(s)) + } + return out +} + +func renderKeywordTable(kws []db.ServiceKeyword) string { + out := ` + + ` + if len(kws) == 0 { + out += `` + } + for _, kw := range kws { + out += fmt.Sprintf(` + + + + + `, htmlEscape(kw.ServiceName), htmlEscape(kw.Keyword), kw.KeywordID) + } + out += `
ServiceKeywordActions
No keywords defined.
%s%s +
+ +
+
` + return out +} + +func renderStatusTable(statuses []db.LeadStatus) string { + out := ` + + ` + if len(statuses) == 0 { + out += `` + } + for _, s := range statuses { + out += fmt.Sprintf(` + + + + `, htmlEscape(s.StatusName), s.StatusID) + } + out += `
StatusActions
No statuses defined.
%s +
+ +
+
` + return out +} + +// --- package-level shims kept for existing tests --- + +func LeadKeywordsPage(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).LeadKeywordsPage(w, r) +} +func AddServiceKeyword(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).AddServiceKeyword(w, r) +} +func DeleteServiceKeywordHandler(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).DeleteServiceKeywordHandler(w, r) +} +func AddLeadStatusHandler(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).AddLeadStatusHandler(w, r) +} +func DeleteLeadStatusHandler(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).DeleteLeadStatusHandler(w, r) +} diff --git a/apps/go-crm/internal/handlers/lead_review.go b/apps/go-crm/internal/handlers/lead_review.go new file mode 100644 index 0000000..2c56487 --- /dev/null +++ b/apps/go-crm/internal/handlers/lead_review.go @@ -0,0 +1,423 @@ +package handlers + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "go-crm/internal/db" + + "github.com/go-chi/chi/v5" +) + +// LeadReviewQueue renders the review queue: leads where needs_review = 1. +// GET /leads/review +func (a *App) LeadReviewQueue(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) + if !ok { + return + } + + clientID := a.clientIDForAccount(accountID) + if clientID == 0 { + http.Error(w, "No client found for account", http.StatusBadRequest) + return + } + + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + if limit == 0 { + limit = 50 + } + + leads, err := db.ListLeadsForReview(a.DB, clientID, limit, offset) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + statuses, _ := db.ListLeadStatuses(a.DB, clientID) + services := a.serviceNames(clientID) + + pendingCount := len(leads) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, ` + + + Review Queue (%d pending) + + + + +

Review Queue %d

+

Leads with service interest not yet identified. Confirm or correct each entry.

+ +
+`, pendingCount, pendingCount) + + if len(leads) == 0 { + fmt.Fprintf(w, `
No leads pending review.
`) + } else { + fmt.Fprintf(w, ` + + + + + + + + + + + `) + + for _, l := range leads { + arrived := time.Unix(l.CreatedAt, 0).Format("02/01 15:04") + fmt.Fprintf(w, ` + + + + + + + + `, + l.LeadID, + htmlEscape(l.PhoneNormalized), + htmlEscape(l.Name), + l.LeadID, l.LeadID, + buildServiceOptions(services, l.ServiceInterest), + buildStatusOptions(statuses, l.Status), + htmlEscape(l.Name), + htmlEscape(l.Status), + arrived, + l.LeadID, l.LeadID, + ) + } + fmt.Fprintf(w, `
PhoneNameService InterestStatusArrivedActions
%s%s +
+ + + +
+ +
+
+
%s%s +
+ +
+
`) + } + + fmt.Fprintf(w, ``) +} + +// ConfirmLeadReview handles the form submission from the review queue. +// PUT /leads/{id}/review +func (a *App) ConfirmLeadReview(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) + if !ok { + return + } + + clientID := a.clientIDForAccount(accountID) + if clientID == 0 { + http.Error(w, "No client found", http.StatusBadRequest) + return + } + + leadID, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + r.ParseForm() + + lead, err := db.GetLeadByID(a.DB, clientID, leadID) + if err != nil { + http.Error(w, "Lead not found", http.StatusNotFound) + return + } + + lead.Name = r.FormValue("name") + lead.ServiceInterest = r.FormValue("service_interest") + lead.Status = r.FormValue("status") + lead.NeedsReview = false + + if err := db.UpdateLead(a.DB, lead); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, ``, leadID) +} + +// LeadAllList renders all leads (not just review queue). +// GET /leads/all +func (a *App) LeadAllList(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) + if !ok { + return + } + + clientID := a.clientIDForAccount(accountID) + if clientID == 0 { + http.Error(w, "No client found for account", http.StatusBadRequest) + return + } + + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + if limit == 0 { + limit = 50 + } + + var leads []db.Lead + var err error + if a.LeadService != nil { + domainLeads, svcErr := a.LeadService.ListAllLeads(r.Context(), clientID, limit, offset) + if svcErr != nil { + http.Error(w, svcErr.Error(), http.StatusInternalServerError) + return + } + // convert domain.Leaf to db.Lead + leads = make([]db.Lead, len(domainLeads)) + for i, dl := range domainLeads { + leads[i] = db.Lead{ + LeadID: dl.LeadID, + ClientID: dl.ClientID, + Name: dl.Name, + PhoneRaw: dl.PhoneRaw, + PhoneNormalized: dl.PhoneNormalized, + ServiceInterest: dl.ServiceInterest, + Status: dl.Status, + NeedsReview: dl.NeedsReview, + AppointmentDate: dl.AppointmentDate, + AppointmentTime: dl.AppointmentTime, + PaymentStatus: dl.PaymentStatus, + PaymentAmount: dl.PaymentAmount, + PaymentDate: dl.PaymentDate, + CreatedAt: dl.CreatedAt, + LastContactAt: dl.LastContactAt, + } + } + } else { + leads, err = db.ListAllLeads(a.DB, clientID, limit, offset) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + pendingCount, _ := db.CountLeadsNeedingReview(a.DB, clientID) + statuses, _ := db.ListLeadStatuses(a.DB, clientID) + services := a.serviceNames(clientID) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, ` + + + All Leads + + + + +

All Leads

+ +
+ + + + + + + + +`, pendingCount) + + for _, l := range leads { + arrived := time.Unix(l.CreatedAt, 0).Format("02/01/2006 15:04") + lastContact := time.Unix(l.LastContactAt, 0).Format("02/01/2006 15:04") + rowClass := "" + if l.NeedsReview { + rowClass = `class="needs-review"` + } + fmt.Fprintf(w, ` + + + + + + + + + + + + + `, + rowClass, l.LeadID, + htmlEscape(l.PhoneNormalized), + htmlEscape(l.Name), + htmlEscape(l.ServiceInterest), + htmlEscape(l.Status), + htmlEscape(l.PaymentStatus), + arrived, + lastContact, + l.LeadID, + l.LeadID, l.LeadID, + l.LeadID, + l.LeadID, l.LeadID, + htmlEscape(l.Name), + buildServiceOptions(services, l.ServiceInterest), + buildStatusOptions(statuses, l.Status), + l.LeadID, + ) + } + + fmt.Fprintf(w, `
PhoneNameServiceStatusPaymentArrivedLast ContactActions
%s%s%s%s%s%s%s + +
+ +
+
`) +} + +// DeleteLeadNew handles DELETE /leads/{id} +func (a *App) DeleteLeadNew(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) + if !ok { + return + } + clientID := a.clientIDForAccount(accountID) + leadID, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + a.DB.Exec("DELETE FROM leads WHERE lead_id = ? AND client_id = ?", leadID, clientID) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte("")) +} + +// serviceNames returns the list of service names for the review/all-leads dropdowns. +// It reads from the DB-backed services table first, then falls back to the +// hardcoded defaults so the dropdown is never empty on a fresh install. +func (a *App) serviceNames(clientID int64) []string { + rows, err := a.DB.Query( + "SELECT DISTINCT name FROM services WHERE client_id = ? ORDER BY name", + clientID, + ) + if err == nil { + defer rows.Close() + var names []string + for rows.Next() { + var n string + if rows.Scan(&n) == nil { + names = append(names, n) + } + } + if len(names) > 0 { + // Ensure "Não especificou" is always first. + hasDefault := false + for _, n := range names { + if n == "Não especificou" { + hasDefault = true + break + } + } + if !hasDefault { + names = append([]string{"Não especificou"}, names...) + } + return names + } + } + // Fallback: hardcoded defaults for a fresh install with no services yet. + return []string{ + "Não especificou", + "Head Spa", + "Massagem completa", + "Drenagem linfatica", + "Hydra Boost", + "Design Henna", + "Masculino", + } +} + +// --- rendering helpers ------------------------------------------------------- + +func buildServiceOptions(services []string, selected string) string { + out := "" + for _, s := range services { + sel := "" + if s == selected { + sel = ` selected` + } + out += fmt.Sprintf(``, htmlEscape(s), sel, htmlEscape(s)) + } + return out +} + +func buildStatusOptions(statuses []db.LeadStatus, selected string) string { + out := "" + for _, s := range statuses { + sel := "" + if s.StatusName == selected { + sel = ` selected` + } + out += fmt.Sprintf(``, htmlEscape(s.StatusName), sel, htmlEscape(s.StatusName)) + } + return out +} + +func htmlEscape(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, `"`, """) + return s +} + +// --- package-level shims kept for existing tests --- + +func LeadReviewQueue(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).LeadReviewQueue(w, r) +} +func ConfirmLeadReview(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).ConfirmLeadReview(w, r) +} +func LeadAllList(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).LeadAllList(w, r) +} +func DeleteLeadNew(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).DeleteLeadNew(w, r) +}