Add leads handlers, WhatsApp integration, and test files
This commit is contained in:
313
apps/go-crm/internal/handlers/leads.go
Normal file
313
apps/go-crm/internal/handlers/leads.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go-crm/internal/db"
|
||||
"go-crm/internal/whatsapp"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func ListLeads(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := 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 customer_id, client_id, name, phone, birth_date, instagram, created_at FROM customers WHERE client_id IN (SELECT client_id FROM clients WHERE account_id = ?)"
|
||||
args := []interface{}{accountID}
|
||||
|
||||
if search != "" {
|
||||
query += " AND (name LIKE ? OR phone LIKE ?)"
|
||||
searchPat := "%" + search + "%"
|
||||
args = append(args, searchPat, searchPat)
|
||||
}
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := 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); err != nil {
|
||||
continue
|
||||
}
|
||||
customers = append(customers, c)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
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>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody id="leadList">
|
||||
`))
|
||||
|
||||
for _, c := range customers {
|
||||
w.Write([]byte(`<tr>
|
||||
<td>` + c.Name + `</td>
|
||||
<td>` + c.Phone + `</td>
|
||||
<td>` + c.BirthDate + `</td>
|
||||
<td>` + c.Instagram + `</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="5">
|
||||
<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="/clients">Back to Clients</a></p>
|
||||
</body></html>`))
|
||||
}
|
||||
|
||||
func UpdateLead(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
r.ParseForm()
|
||||
|
||||
_, err := 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
|
||||
}
|
||||
|
||||
ListLeads(w, r)
|
||||
}
|
||||
|
||||
func DeleteLead(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
|
||||
_, err := 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")
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
func LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
clientID, _ := strconv.ParseInt(r.URL.Query().Get("client_id"), 10, 64)
|
||||
client, err := db.GetClientByID(DB, accountID, clientID)
|
||||
if err != nil {
|
||||
http.Error(w, "Client not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
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) {
|
||||
// Only re-render QR if the code actually changed.
|
||||
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!';
|
||||
document.getElementById('qrcode').innerHTML = '✓';
|
||||
// Stop polling — connected.
|
||||
} else if (data.status === 'error') {
|
||||
document.getElementById('status').textContent = 'Error: ' + (data.error || 'Unknown') + ' — retrying...';
|
||||
// Back off longer on error to let server recover.
|
||||
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="/clients">Back to Clients</a></p>
|
||||
</body></html>`))
|
||||
}
|
||||
|
||||
func jsonEscape(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func LeadsQR(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
clientID, _ := strconv.ParseInt(r.URL.Query().Get("client_id"), 10, 64)
|
||||
client, err := db.GetClientByID(DB, accountID, clientID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"status":"error","error":"client not found"}`))
|
||||
return
|
||||
}
|
||||
|
||||
if WAConnector == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
w.Write([]byte(`{"status":"error","error":"WhatsApp not configured - contact admin"}`))
|
||||
return
|
||||
}
|
||||
|
||||
connected, err := WAConnector.IsConnected(r.Context(), clientID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
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")
|
||||
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"ready"}`))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
log.Printf("QR: Starting Connect for client %d", clientID)
|
||||
|
||||
// Use context.Background() so the WA session goroutine outlives this HTTP request.
|
||||
// The handler returns after the first QR frame; subsequent polls reuse the same session.
|
||||
qrChan, err := 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user