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
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-crm/internal/db"
|
||||
@@ -14,8 +15,8 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func ListLeads(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := requireAuth(w, r)
|
||||
func (a *App) ListLeads(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -27,7 +28,7 @@ func ListLeads(w http.ResponseWriter, r *http.Request) {
|
||||
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 = ?)"
|
||||
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 != "" {
|
||||
@@ -36,10 +37,10 @@ func ListLeads(w http.ResponseWriter, r *http.Request) {
|
||||
args = append(args, searchPat, searchPat)
|
||||
}
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
query += " ORDER BY cu.created_at DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := DB.Query(query, args...)
|
||||
rows, err := a.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -49,13 +50,13 @@ func ListLeads(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
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")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -80,17 +81,24 @@ func ListLeads(w http.ResponseWriter, r *http.Request) {
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Phone</th><th>Birth Date</th><th>Instagram</th><th>Actions</th></tr>
|
||||
<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">
|
||||
@@ -99,7 +107,7 @@ func ListLeads(w http.ResponseWriter, r *http.Request) {
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="editLead` + strconv.FormatInt(c.CustomerID, 10) + `" class="edit-row">
|
||||
<td colspan="5">
|
||||
<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 + `">
|
||||
@@ -112,12 +120,12 @@ func ListLeads(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Write([]byte(`</tbody></table>
|
||||
<p><a href="/clients">Back to Clients</a></p>
|
||||
<p><a href="/">Back to Home</a> | <a href="/clients">Back to Clients</a></p>
|
||||
</body></html>`))
|
||||
}
|
||||
|
||||
func UpdateLead(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := requireAuth(w, r)
|
||||
func (a *App) UpdateLead(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -125,7 +133,7 @@ func UpdateLead(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
r.ParseForm()
|
||||
|
||||
_, err := DB.Exec(
|
||||
_, 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,
|
||||
)
|
||||
@@ -134,18 +142,18 @@ func UpdateLead(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ListLeads(w, r)
|
||||
a.ListLeads(w, r)
|
||||
}
|
||||
|
||||
func DeleteLead(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := requireAuth(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 := DB.Exec(
|
||||
_, err := a.DB.Exec(
|
||||
"DELETE FROM customers WHERE customer_id = ? AND client_id IN (SELECT client_id FROM clients WHERE account_id = ?)",
|
||||
id, accountID,
|
||||
)
|
||||
@@ -154,24 +162,24 @@ func DeleteLead(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
func LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := requireAuth(w, r)
|
||||
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(DB, accountID, clientID)
|
||||
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")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -196,7 +204,6 @@ func LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
|
||||
.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 = '';
|
||||
@@ -209,12 +216,29 @@ func LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
|
||||
document.getElementById('status').textContent = 'Scan with WhatsApp';
|
||||
setTimeout(pollQR, 5000);
|
||||
} else if (data.status === 'ready') {
|
||||
document.getElementById('status').textContent = 'Connected!';
|
||||
document.getElementById('status').textContent = 'Connected! Verifying phone...';
|
||||
document.getElementById('qrcode').innerHTML = '✓';
|
||||
// Stop polling — connected.
|
||||
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...';
|
||||
// Back off longer on error to let server recover.
|
||||
setTimeout(pollQR, 8000);
|
||||
} else {
|
||||
document.getElementById('status').textContent = 'Status: ' + data.status;
|
||||
@@ -228,7 +252,7 @@ func LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
pollQR();
|
||||
</script>
|
||||
<p><a href="/clients">Back to Clients</a></p>
|
||||
<p><a href="/">Back to Home</a> | <a href="/clients">Back to Clients</a></p>
|
||||
</body></html>`))
|
||||
}
|
||||
|
||||
@@ -237,46 +261,44 @@ func jsonEscape(s string) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func LeadsQR(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := requireAuth(w, r)
|
||||
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(DB, accountID, clientID)
|
||||
client, err := db.GetClientByID(a.DB, accountID, clientID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
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 WAConnector == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
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 := WAConnector.IsConnected(r.Context(), clientID)
|
||||
connected, err := a.WAConnector.IsConnected(r.Context(), clientID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
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")
|
||||
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")
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
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)
|
||||
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()) + `}`))
|
||||
@@ -311,3 +333,52 @@ func LeadsQR(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user