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(`
| Name | Phone | Birth Date | Actions | |
|---|---|---|---|---|
| ` + c.Name + ` | ` + c.Phone + ` | ` + c.BirthDate + ` | ` + c.Instagram + ` | View |
Phone: ` + c.Phone + `
Birth Date: ` + c.BirthDate + `
Instagram: ` + c.Instagram + `
Back