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
This commit is contained in:
423
apps/go-crm/internal/handlers/lead_review.go
Normal file
423
apps/go-crm/internal/handlers/lead_review.go
Normal file
@@ -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, `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Review Queue (%d pending)</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 1rem; }
|
||||
h1 { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.badge { background: #dc3545; color: #fff; border-radius: 1rem; padding: 0.2rem 0.6rem; font-size: 0.85rem; }
|
||||
table { border-collapse: collapse; width: 100%%; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; vertical-align: top; }
|
||||
th { background: #f5f5f5; }
|
||||
form { display: inline; }
|
||||
select, input[type=text] { width: 100%%; box-sizing: border-box; }
|
||||
.actions button { margin-right: 4px; }
|
||||
.empty { padding: 2rem; color: #888; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Review Queue <span class="badge">%d</span></h1>
|
||||
<p>Leads with service interest not yet identified. Confirm or correct each entry.</p>
|
||||
<nav><a href="/">Home</a> | <a href="/leads/all">All Leads</a> | <a href="/leads/keywords">Keyword Mapping</a> | <a href="/report">Monthly Report</a></nav>
|
||||
<br>
|
||||
`, pendingCount, pendingCount)
|
||||
|
||||
if len(leads) == 0 {
|
||||
fmt.Fprintf(w, `<div class="empty">No leads pending review.</div>`)
|
||||
} else {
|
||||
fmt.Fprintf(w, `<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Phone</th>
|
||||
<th>Name</th>
|
||||
<th>Service Interest</th>
|
||||
<th>Status</th>
|
||||
<th>Arrived</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reviewList">`)
|
||||
|
||||
for _, l := range leads {
|
||||
arrived := time.Unix(l.CreatedAt, 0).Format("02/01 15:04")
|
||||
fmt.Fprintf(w, `
|
||||
<tr id="row-%d">
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>
|
||||
<form hx-put="/leads/%d/review" hx-target="#row-%d" hx-swap="outerHTML">
|
||||
<select name="service_interest">%s</select>
|
||||
<select name="status">%s</select>
|
||||
<input type="text" name="name" value="%s" placeholder="Name">
|
||||
<div class="actions">
|
||||
<button type="submit">Confirm</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>
|
||||
<form hx-delete="/leads/%d" hx-target="#row-%d" hx-swap="outerHTML">
|
||||
<button type="submit" onclick="return confirm('Delete this lead?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>`,
|
||||
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, `</tbody></table>`)
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, `</body></html>`)
|
||||
}
|
||||
|
||||
// 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, `<tr id="row-%d" style="display:none"></tr>`, 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, `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>All 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; }
|
||||
.badge { background: #dc3545; color: #fff; border-radius: 1rem; padding: 0.2rem 0.6rem; font-size: 0.8rem; }
|
||||
.needs-review { background: #fff3cd; }
|
||||
select, input[type=text] { width: 100%%; box-sizing: border-box; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>All Leads</h1>
|
||||
<nav>
|
||||
<a href="/">Home</a> |
|
||||
<a href="/leads/review">Review Queue <span class="badge">%d</span></a> |
|
||||
<a href="/leads/keywords">Keyword Mapping</a> |
|
||||
<a href="/report">Monthly Report</a>
|
||||
</nav>
|
||||
<br>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Phone</th><th>Name</th><th>Service</th><th>Status</th>
|
||||
<th>Payment</th><th>Arrived</th><th>Last Contact</th><th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="leadsList">
|
||||
`, 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, `
|
||||
<tr %s id="lead-%d">
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>
|
||||
<button onclick="document.getElementById('edit-%d').style.display='table-row'">Edit</button>
|
||||
<form hx-delete="/leads/%d" hx-target="#lead-%d" hx-swap="outerHTML" style="display:inline">
|
||||
<button onclick="return confirm('Delete?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="edit-%d" style="display:none">
|
||||
<td colspan="8">
|
||||
<form hx-put="/leads/%d/review" hx-target="#lead-%d" hx-swap="outerHTML">
|
||||
<input type="text" name="name" value="%s" placeholder="Name">
|
||||
<select name="service_interest">%s</select>
|
||||
<select name="status">%s</select>
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" onclick="document.getElementById('edit-%d').style.display='none'">Cancel</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>`,
|
||||
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, `</tbody></table></body></html>`)
|
||||
}
|
||||
|
||||
// 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(`<option value="%s"%s>%s</option>`, 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(`<option value="%s"%s>%s</option>`, 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)
|
||||
}
|
||||
Reference in New Issue
Block a user