Files
workspace/apps/go-crm/internal/handlers/leads.go
gabspereira 744868caa1 feat(go-crm): full auth, routing, middleware, and supporting infra
- Add auth handlers (signup, login, logout, account management) with bcrypt
- Add client, customer, service, scheduling, payment, question, answer handlers
- Add dashboard, monthly report, and lead pipeline pages
- Add UTF-8 middleware to force charset on HTML responses
- Add config package with env-based overrides for DB path, secrets, endpoints
- Add parser package for WhatsApp message ingestion
- Add clean-arch layers: pkg/domain, pkg/repo, pkg/usecase for leads
- Add cmd/migrate utility for DB migrations
- Add Makefile, README, run-tests.sh, and dev scripts
- Update docker-compose.yml with memory limits
- Update .air.toml to exclude DB files and stop on errors
- Update whatsapp-sync dependencies and add src/index.js entrypoint
- Add whatsme standalone WhatsApp reader app (source only)
- Untrack .opencode-sandbox/data/go-crm.db from git history
- Expand root .gitignore: ngrok, tmp dirs, sandbox DBs, compiled binaries
2026-05-23 16:55:55 -03:00

385 lines
14 KiB
Go

package handlers
import (
"context"
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
"time"
"go-crm/internal/db"
"go-crm/internal/whatsapp"
"github.com/go-chi/chi/v5"
)
func (a *App) ListLeads(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
return
}
search := r.URL.Query().Get("search")
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if limit == 0 {
limit = 20
}
query := "SELECT cu.customer_id, cu.client_id, cu.name, cu.phone, cu.birth_date, cu.instagram, cu.created_at, COALESCE(cl.whatsapp_connected,0), COALESCE(cl.whatsapp_number,'') FROM customers cu JOIN clients cl ON cu.client_id = cl.client_id WHERE cl.account_id = ?"
args := []interface{}{accountID}
if search != "" {
query += " AND (name LIKE ? OR phone LIKE ?)"
searchPat := "%" + search + "%"
args = append(args, searchPat, searchPat)
}
query += " ORDER BY cu.created_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := a.DB.Query(query, args...)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
var customers []db.Customer
for rows.Next() {
var c db.Customer
if err := rows.Scan(&c.CustomerID, &c.ClientID, &c.Name, &c.Phone, &c.BirthDate, &c.Instagram, &c.CreatedAt, &c.WhatsAppConnected, &c.WhatsAppNumber); err != nil {
continue
}
customers = append(customers, c)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html>
<html>
<head>
<title>Leads</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
body { font-family: sans-serif; padding: 1rem; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #f5f5f5; }
.search-box { margin-bottom: 1rem; }
.edit-row { display: none; }
</style>
</head>
<body>
<h1>Leads</h1>
<div class="search-box">
<form hx-get="/leads" hx-target="#leadList" hx-swap="innerHTML">
<input type="text" name="search" placeholder="Search by name or phone" value="` + search + `">
<button type="submit">Search</button>
</form>
</div>
<table>
<thead>
<tr><th>Name</th><th>Phone</th><th>Birth Date</th><th>Instagram</th><th>WhatsApp</th><th>Actions</th></tr>
</thead>
<tbody id="leadList">
`))
for _, c := range customers {
waStatus := "Not connected"
waStyle := "color: #999;"
if c.WhatsAppConnected == 1 && c.WhatsAppNumber != "" {
waStatus = c.WhatsAppNumber
waStyle = "color: #28a745; font-weight: 600;"
}
w.Write([]byte(`<tr>
<td>` + c.Name + `</td>
<td>` + c.Phone + `</td>
<td>` + c.BirthDate + `</td>
<td>` + c.Instagram + `</td>
<td style="` + waStyle + `">` + waStatus + `</td>
<td>
<button type="button" onclick="document.getElementById('editLead` + strconv.FormatInt(c.CustomerID, 10) + `').style.display='table-row'">Edit</button>
<form method="DELETE" style="display:inline" hx-delete="/leads/` + strconv.FormatInt(c.CustomerID, 10) + `" hx-target="closest tr">
<button type="submit">Delete</button>
</form>
</td>
</tr>
<tr id="editLead` + strconv.FormatInt(c.CustomerID, 10) + `" class="edit-row">
<td colspan="6">
<form hx-put="/leads/` + strconv.FormatInt(c.CustomerID, 10) + `" hx-target="#leadList" hx-swap="innerHTML">
<input type="text" name="name" value="` + c.Name + `">
<input type="tel" name="phone" value="` + c.Phone + `">
<input type="date" name="birth_date" value="` + c.BirthDate + `">
<input type="text" name="instagram" value="` + c.Instagram + `">
<button type="submit">Save</button>
</form>
</td>
</tr>`))
}
w.Write([]byte(`</tbody></table>
<p><a href="/">Back to Home</a> | <a href="/clients">Back to Clients</a></p>
</body></html>`))
}
func (a *App) UpdateLead(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
return
}
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
r.ParseForm()
_, err := a.DB.Exec(
"UPDATE customers SET name = ?, phone = ?, birth_date = ?, instagram = ? WHERE customer_id = ? AND client_id IN (SELECT client_id FROM clients WHERE account_id = ?)",
r.FormValue("name"), r.FormValue("phone"), r.FormValue("birth_date"), r.FormValue("instagram"), id, accountID,
)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
a.ListLeads(w, r)
}
func (a *App) DeleteLead(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
return
}
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
_, err := a.DB.Exec(
"DELETE FROM customers WHERE customer_id = ? AND client_id IN (SELECT client_id FROM clients WHERE account_id = ?)",
id, accountID,
)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte("OK"))
}
func (a *App) LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
return
}
clientID, _ := strconv.ParseInt(r.URL.Query().Get("client_id"), 10, 64)
client, err := db.GetClientByID(a.DB, accountID, clientID)
if err != nil {
http.Error(w, "Client not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html>
<html>
<head>
<title>Connect WhatsApp</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<style>
body { font-family: sans-serif; padding: 2rem; text-align: center; }
#qrcode { margin: 2rem auto; display: flex; justify-content: center; }
#status { padding: 1rem; }
</style>
</head>
<body>
<h1>Connect WhatsApp</h1>
<p>Scan the QR code below with your WhatsApp app to connect</p>
<div id="qrcode"></div>
<p id="status">Loading...</p>
<script>
var lastQR = '';
function pollQR() {
fetch('/leads/qr?client_id=` + strconv.FormatInt(client.ClientID, 10) + `')
.then(r => r.json())
.then(data => {
if (data.qr) {
if (data.qr !== lastQR) {
lastQR = data.qr;
document.getElementById('qrcode').innerHTML = '';
new QRCode(document.getElementById('qrcode'), {
text: data.qr,
width: 256,
height: 256
});
}
document.getElementById('status').textContent = 'Scan with WhatsApp';
setTimeout(pollQR, 5000);
} else if (data.status === 'ready') {
document.getElementById('status').textContent = 'Connected! Verifying phone...';
document.getElementById('qrcode').innerHTML = '&#10003;';
setTimeout(() => {
fetch('/leads/verify/` + strconv.FormatInt(client.ClientID, 10) + `')
.then(r => r.json())
.then(v => {
var msg = 'WhatsApp: ' + (v.wa_phone || 'unknown');
if (v.match === 'yes') {
document.getElementById('status').textContent = msg + ' — matches ' + (v.client_phone || '') + ' ✓';
document.getElementById('status').style.color = '#28a745';
} else if (v.match === 'no') {
document.getElementById('status').textContent = msg + ' — does NOT match client phone ' + (v.client_phone || '') + ' ⚠';
document.getElementById('status').style.color = '#dc3545';
} else {
document.getElementById('status').textContent = msg + ' (client phone unknown — verify manually)';
}
})
.catch(() => {
document.getElementById('status').textContent = 'Connected! (could not verify phone)';
});
}, 1500);
} else if (data.status === 'error') {
document.getElementById('status').textContent = 'Error: ' + (data.error || 'Unknown') + ' — retrying...';
setTimeout(pollQR, 8000);
} else {
document.getElementById('status').textContent = 'Status: ' + data.status;
setTimeout(pollQR, 5000);
}
})
.catch(err => {
document.getElementById('status').textContent = 'Connection error — retrying...';
setTimeout(pollQR, 5000);
});
}
pollQR();
</script>
<p><a href="/">Back to Home</a> | <a href="/clients">Back to Clients</a></p>
</body></html>`))
}
func jsonEscape(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
func (a *App) LeadsQR(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
return
}
clientID, _ := strconv.ParseInt(r.URL.Query().Get("client_id"), 10, 64)
client, err := db.GetClientByID(a.DB, accountID, clientID)
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"status":"error","error":"client not found"}`))
return
}
if a.WAConnector == nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte(`{"status":"error","error":"WhatsApp not configured - contact admin"}`))
return
}
connected, err := a.WAConnector.IsConnected(r.Context(), clientID)
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape(err.Error()) + `}`))
return
}
if connected {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"ready"}`))
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
log.Printf("QR: Starting Connect for client %d", clientID)
qrChan, err := a.WAConnector.Connect(context.Background(), clientID)
if err != nil {
log.Printf("QR: Connect returned error for client %d: %v", clientID, err)
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape(err.Error()) + `}`))
return
}
timeout := time.After(30 * time.Second)
for {
select {
case <-timeout:
log.Printf("QR: Timeout waiting for client %d", clientID)
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape("timeout waiting for QR code") + `}`))
return
case frame, ok := <-qrChan:
if !ok {
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":"connection closed"}`))
return
}
if frame.QR != "" {
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"qr":` + jsonEscape(frame.QR) + `,"status":"waiting"}`))
return
}
if frame.State == whatsapp.StateConnected {
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"ready"}`))
return
}
if frame.State == whatsapp.StateFailed {
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape(frame.Error) + `}`))
return
}
}
}
}
func (a *App) VerifyLead(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
return
}
clientID, _ := strconv.ParseInt(chi.URLParam(r, "client_id"), 10, 64)
client, err := db.GetClientByID(a.DB, accountID, clientID)
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Write([]byte(`{"status":"error","error":"client not found"}`))
return
}
match := "unknown"
if client.WhatsAppNumber != "" && client.Phone != "" {
cleanWA := strings.TrimPrefix(client.WhatsAppNumber, "+")
cleanClient := strings.TrimPrefix(client.Phone, "+")
if cleanWA == cleanClient {
match = "yes"
} else {
match = "no"
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Write([]byte(`{"status":"ok","wa_phone":"` + jsonEscape(client.WhatsAppNumber) + `","client_phone":"` + jsonEscape(client.Phone) + `","match":"` + match + `","client_name":"` + jsonEscape(client.Name) + `"}`))
}
// --- package-level shims kept for existing tests ---
func ListLeads(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).ListLeads(w, r)
}
func UpdateLead(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).UpdateLead(w, r)
}
func DeleteLead(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).DeleteLead(w, r)
}
func LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).LeadsConnectPage(w, r)
}
func LeadsQR(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).LeadsQR(w, r)
}
func VerifyLead(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).VerifyLead(w, r)
}